Metadatasource cleanup docs (#5861)

This PR includes documentation updates for the new `metadatasource`
plugin architecture, as requested by @snejus. The docs changes were
split out from the original implementation to keep things focused and
reviewable.

* Introduces comprehensive documentation for the new metadata plugin
system.
* Performs a general cleanup of the plugin-related developer
documentation for clarity and consistency.
* Splits and cleanup for quite some files in the dev docs
This commit is contained in:
Sebastian Mohr 2025-09-04 17:11:35 +02:00 committed by GitHub
commit 55667fa1e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 970 additions and 739 deletions

View file

@ -67,3 +67,7 @@ ab5acaabb3cd24c482adb7fa4800c89fd6a2f08d
2fccf64efe82851861e195b521b14680b480a42a
# Do not use explicit indices for logging args when not needed
d93ddf8dd43e4f9ed072a03829e287c78d2570a2
# Moved dev docs
07549ed896d9649562d40b75cd30702e6fa6e975
# Moved plugin docs Further Reading chapter
33f1a5d0bef8ca08be79ee7a0d02a018d502680d

View file

@ -143,4 +143,4 @@ jobs:
run: poe lint-docs
- name: Build docs
run: poe docs -e 'SPHINXOPTS=--fail-on-warning --keep-going'
run: poe docs -- -e 'SPHINXOPTS=--fail-on-warning --keep-going'

View file

@ -8,7 +8,6 @@ implemented as plugins.
from __future__ import annotations
import abc
import inspect
import re
import warnings
from typing import TYPE_CHECKING, Generic, Literal, Sequence, TypedDict, TypeVar
@ -421,13 +420,3 @@ class SearchApiMetadataSourcePlugin(
query = unidecode.unidecode(query)
return query
# Dynamically copy methods to BeetsPlugin for legacy support
# TODO: Remove this in the future major release, v3.0.0
for name, method in inspect.getmembers(
MetadataSourcePlugin, predicate=inspect.isfunction
):
if not hasattr(BeetsPlugin, name):
setattr(BeetsPlugin, name, method)

View file

@ -158,6 +158,21 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
early_import_stages: list[ImportStageFunc]
import_stages: list[ImportStageFunc]
def __init_subclass__(cls) -> None:
# Dynamically copy methods to BeetsPlugin for legacy support
# TODO: Remove this in the future major release, v3.0.0
if inspect.isabstract(cls):
return
from beets.metadata_plugins import MetadataSourcePlugin
abstractmethods = MetadataSourcePlugin.__abstractmethods__
for name, method in inspect.getmembers(
MetadataSourcePlugin, predicate=inspect.isfunction
):
if name not in abstractmethods and not hasattr(cls, name):
setattr(cls, name, method)
def __init__(self, name: str | None = None):
"""Perform one-time plugin setup."""

View file

@ -1,4 +1,4 @@
{{ fullname | escape | underline}}
{{ name | escape | underline}}
.. currentmodule:: {{ module }}

9
docs/api/index.rst Normal file
View file

@ -0,0 +1,9 @@
API Reference
=============
.. toctree::
:maxdepth: 2
:titlesonly:
plugins
database

View file

@ -7,3 +7,11 @@ Plugins
:toctree: generated/
BeetsPlugin
.. currentmodule:: beets.metadata_plugins
.. autosummary::
:toctree: generated/
MetadataSourcePlugin
SearchApiMetadataSourcePlugin

View file

@ -4178,7 +4178,7 @@ fetching cover art for your music, enable this plugin after upgrading to beets
"database is locked"). This release synchronizes access to the database to
avoid internal SQLite contention, which should avoid this error.
- Plugins can now add parallel stages to the import pipeline. See
:ref:`writing-plugins`.
:ref:`basic-plugin-setup`.
- Beets now prints out an error when you use an unrecognized field name in a
query: for example, when running ``beet ls -a artist:foo`` (because ``artist``
is an item-level field).
@ -4361,7 +4361,7 @@ to come in the next couple of releases.
addition to replacing them) if the special string ``<strip>`` is specified as
the replacement.
- New plugin API: plugins can now add fields to the MediaFile tag abstraction
layer. See :ref:`writing-plugins`.
layer. See :ref:`basic-plugin-setup`.
- A reasonable error message is now shown when the import log file cannot be
opened.
- The import log file is now flushed and closed properly so that it can be used
@ -4405,7 +4405,7 @@ filenames that would otherwise conflict. Three new plugins (``inline``,
naming rules: for example, ``%upper{%left{$artist,1}}`` will insert the
capitalized first letter of the track's artist. For more details, see
:doc:`/reference/pathformat`. If you're interested in adding your own template
functions via a plugin, see :ref:`writing-plugins`.
functions via a plugin, see :ref:`basic-plugin-setup`.
- Plugins can also now define new path *fields* in addition to functions.
- The new :doc:`/plugins/inline` lets you **use Python expressions to customize
path formats** by defining new fields in the config file.

View file

@ -1,4 +1,3 @@
..
code_of_conduct:
.. code_of_conduct:
.. include:: ../CODE_OF_CONDUCT.rst

View file

@ -75,13 +75,29 @@ man_pages = [
),
]
# Global substitutions that can be used anywhere in the documentation.
rst_epilog = """
.. |Album| replace:: :class:`~beets.library.models.Album`
.. |AlbumInfo| replace:: :class:`beets.autotag.hooks.AlbumInfo`
.. |ImportSession| replace:: :class:`~beets.importer.session.ImportSession`
.. |ImportTask| replace:: :class:`~beets.importer.tasks.ImportTask`
.. |Item| replace:: :class:`~beets.library.models.Item`
.. |Library| replace:: :class:`~beets.library.library.Library`
.. |Model| replace:: :class:`~beets.dbcore.db.Model`
.. |TrackInfo| replace:: :class:`beets.autotag.hooks.TrackInfo`
"""
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = "pydata_sphinx_theme"
html_theme_options = {"collapse_navigation": True, "logo": {"text": "beets"}}
html_theme_options = {
"collapse_navigation": False,
"logo": {"text": "beets"},
"show_nav_level": 2, # How many levels in left sidebar to show automatically
"navigation_depth": 4, # How many levels of navigation to expand
}
html_title = "beets"
html_logo = "_static/beets_logo_nobg.png"
html_static_path = ["_static"]

View file

@ -1,4 +1,3 @@
..
contributing:
.. contributing:
.. include:: ../CONTRIBUTING.rst

View file

@ -4,22 +4,20 @@ For Developers
This section contains information for developers. Read on if you're interested
in hacking beets itself or creating plugins for it.
See also the documentation for MediaFile_, the library used by beets to read and
write metadata tags in media files.
See also the documentation for the MediaFile_ and Confuse_ libraries. These are
maintained by the beets team and used to read and write metadata tags and manage
configuration files, respectively.
.. _confuse: https://confuse.readthedocs.io/en/latest/
.. _mediafile: https://mediafile.readthedocs.io/en/latest/
.. toctree::
:maxdepth: 1
:maxdepth: 3
:titlesonly:
plugins
plugins/index
library
importer
cli
.. toctree::
:maxdepth: 1
:caption: API Reference
../api/plugins
../api/database
../api/index

View file

@ -7,18 +7,18 @@ This page describes the internal API of beets' core database features. It
doesn't exhaustively document the API, but is aimed at giving an overview of the
architecture to orient anyone who wants to dive into the code.
The :class:`Library` object is the central repository for data in beets. It
represents a database containing songs, which are :class:`Item` instances, and
groups of items, which are :class:`Album` instances.
The |Library| object is the central repository for data in beets. It represents
a database containing songs, which are |Item| instances, and groups of items,
which are |Album| instances.
The Library Class
-----------------
The :class:`Library` is typically instantiated as a singleton. A single
invocation of beets usually has only one :class:`Library`. It's powered by
:class:`dbcore.Database` under the hood, which handles the SQLite_ abstraction,
something like a very minimal ORM_. The library is also responsible for handling
queries to retrieve stored objects.
The |Library| is typically instantiated as a singleton. A single invocation of
beets usually has only one |Library|. It's powered by :class:`dbcore.Database`
under the hood, which handles the SQLite_ abstraction, something like a very
minimal ORM_. The library is also responsible for handling queries to retrieve
stored objects.
Overview
~~~~~~~~
@ -40,10 +40,9 @@ which you can get using the :py:meth:`Library.transaction` context manager.
Model Classes
-------------
The two model entities in beets libraries, :class:`Item` and :class:`Album`,
share a base class, :class:`LibModel`, that provides common functionality. That
class itself specialises :class:`beets.dbcore.Model` which provides an ORM-like
abstraction.
The two model entities in beets libraries, |Item| and |Album|, share a base
class, :class:`LibModel`, that provides common functionality. That class itself
specialises :class:`beets.dbcore.Model` which provides an ORM-like abstraction.
To get or change the metadata of a model (an item or album), either access its
attributes (e.g., ``print(album.year)`` or ``album.year = 2012``) or use the
@ -56,8 +55,7 @@ Models use dirty-flags to track when the object's metadata goes out of sync with
the database. The dirty dictionary maps field names to booleans indicating
whether the field has been written since the object was last synchronized (via
load or store) with the database. This logic is implemented in the model base
class :class:`LibModel` and is inherited by both :class:`Item` and
:class:`Album`.
class :class:`LibModel` and is inherited by both |Item| and |Album|.
We provide CRUD-like methods for interacting with the database:
@ -77,10 +75,10 @@ normal the normal mapping API is supported:
Item
~~~~
Each :class:`Item` object represents a song or track. (We use the more generic
term item because, one day, beets might support non-music media.) An item can
either be purely abstract, in which case it's just a bag of metadata fields, or
it can have an associated file (indicated by ``item.path``).
Each |Item| object represents a song or track. (We use the more generic term
item because, one day, beets might support non-music media.) An item can either
be purely abstract, in which case it's just a bag of metadata fields, or it can
have an associated file (indicated by ``item.path``).
In terms of the underlying SQLite database, items are backed by a single table
called items with one column per metadata fields. The metadata fields currently
@ -97,12 +95,12 @@ become out of sync with on-disk metadata, mainly to speed up the
:ref:`update-cmd` (which needs to check whether the database is in sync with the
filesystem). This feature turns out to be sort of complicated.
For any :class:`Item`, there are two mtimes: the on-disk mtime (maintained by
the OS) and the database mtime (maintained by beets). Correspondingly, there is
on-disk metadata (ID3 tags, for example) and DB metadata. The goal with the
mtime is to ensure that the on-disk and DB mtimes match when the on-disk and DB
metadata are in sync; this lets beets do a quick mtime check and avoid rereading
files in some circumstances.
For any |Item|, there are two mtimes: the on-disk mtime (maintained by the OS)
and the database mtime (maintained by beets). Correspondingly, there is on-disk
metadata (ID3 tags, for example) and DB metadata. The goal with the mtime is to
ensure that the on-disk and DB mtimes match when the on-disk and DB metadata are
in sync; this lets beets do a quick mtime check and avoid rereading files in
some circumstances.
Specifically, beets attempts to maintain the following invariant:
@ -126,14 +124,14 @@ This leads to the following implementation policy:
Album
~~~~~
An :class:`Album` is a collection of Items in the database. Every item in the
database has either zero or one associated albums (accessible via
``item.album_id``). An item that has no associated album is called a singleton.
Changing fields on an album (e.g. ``album.year = 2012``) updates the album
itself and also changes the same field in all associated items.
An |Album| is a collection of Items in the database. Every item in the database
has either zero or one associated albums (accessible via ``item.album_id``). An
item that has no associated album is called a singleton. Changing fields on an
album (e.g. ``album.year = 2012``) updates the album itself and also changes the
same field in all associated items.
An :class:`Album` object keeps track of album-level metadata, which is (mostly)
a subset of the track-level metadata. The album-level metadata fields are listed
An |Album| object keeps track of album-level metadata, which is (mostly) a
subset of the track-level metadata. The album-level metadata fields are listed
in ``Album._fields``. For those fields that are both item-level and album-level
(e.g., ``year`` or ``albumartist``), every item in an album should share the
same value. Albums use an SQLite table called ``albums``, in which each column
@ -147,7 +145,7 @@ is an album metadata field.
Transactions
~~~~~~~~~~~~
The :class:`Library` class provides the basic methods necessary to access and
The |Library| class provides the basic methods necessary to access and
manipulate its contents. To perform more complicated operations atomically, or
to interact directly with the underlying SQLite database, you must use a
*transaction* (see this `blog post`_ for motivation). For example
@ -181,8 +179,8 @@ matching items/albums.
The ``clause()`` method should return an SQLite ``WHERE`` clause that matches
appropriate albums/items. This allows for efficient batch queries.
Correspondingly, the ``match(item)`` method should take an :class:`Item` object
and return a boolean, indicating whether or not a specific item matches the
Correspondingly, the ``match(item)`` method should take an |Item| object and
return a boolean, indicating whether or not a specific item matches the
criterion. This alternate implementation allows clients to determine whether
items that have already been fetched from the database match the query.
@ -194,4 +192,4 @@ together, matching only albums/items that match all constituent queries.
Beets has a human-writable plain-text query syntax that can be parsed into
:class:`Query` objects. Calling ``AndQuery.from_strings`` parses a list of query
parts into a query object that can then be used with :class:`Library` objects.
parts into a query object that can then be used with |Library| objects.

View file

@ -1,653 +0,0 @@
Plugin Development Guide
========================
Beets plugins are Python modules or packages that extend the core functionality
of beets. The plugin system is designed to be flexible, allowing developers to
add virtually any type of features.
.. _writing-plugins:
Writing Plugins
---------------
A beets plugin is just a Python module or package inside the ``beetsplug``
namespace package. (Check out `this article`_ and `this Stack Overflow
question`_ if you haven't heard about namespace packages.) So, to make one,
create a directory called ``beetsplug`` and add either your plugin module:
::
beetsplug/
myawesomeplugin.py
or your plugin subpackage:
::
beetsplug/
myawesomeplugin/
__init__.py
myawesomeplugin.py
.. attention::
You do not anymore need to add a ``__init__.py`` file to the ``beetsplug``
directory. Python treats your plugin as a namespace package automatically,
thus we do not depend on ``pkgutil``-based setup in the ``__init__.py`` file
anymore.
.. _this article: https://realpython.com/python-namespace-package/#setting-up-some-namespace-packages
.. _this stack overflow question: https://stackoverflow.com/a/27586272/9582674
The meat of your plugin goes in ``myawesomeplugin.py``. There, you'll have to
import ``BeetsPlugin`` from ``beets.plugins`` and subclass it, for example
.. code-block:: python
from beets.plugins import BeetsPlugin
class MyAwesomePlugin(BeetsPlugin):
pass
Once you have your ``BeetsPlugin`` subclass, there's a variety of things your
plugin can do. (Read on!)
To use your new plugin, package your plugin (see how to do this with poetry_ or
setuptools_, for example) and install it into your ``beets`` virtual
environment. Then, add your plugin to beets configuration
.. _poetry: https://python-poetry.org/docs/pyproject/#packages
.. _setuptools: https://setuptools.pypa.io/en/latest/userguide/package_discovery.html#finding-simple-packages
.. code-block:: yaml
# config.yaml
plugins:
- myawesomeplugin
and you're good to go!
.. _add_subcommands:
Add Commands to the CLI
~~~~~~~~~~~~~~~~~~~~~~~
Plugins can add new subcommands to the ``beet`` command-line interface. Define
the plugin class' ``commands()`` method to return a list of ``Subcommand``
objects. (The ``Subcommand`` class is defined in the ``beets.ui`` module.)
Here's an example plugin that adds a simple command:
::
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
my_super_command = Subcommand('super', help='do something super')
def say_hi(lib, opts, args):
print("Hello everybody! I'm a plugin!")
my_super_command.func = say_hi
class SuperPlug(BeetsPlugin):
def commands(self):
return [my_super_command]
To make a subcommand, invoke the constructor like so: ``Subcommand(name, parser,
help, aliases)``. The ``name`` parameter is the only required one and should
just be the name of your command. ``parser`` can be an `OptionParser instance`_,
but it defaults to an empty parser (you can extend it later). ``help`` is a
description of your command, and ``aliases`` is a list of shorthand versions of
your command name.
.. _optionparser instance: https://docs.python.org/library/optparse.html
You'll need to add a function to your command by saying ``mycommand.func =
myfunction``. This function should take the following parameters: ``lib`` (a
beets ``Library`` object) and ``opts`` and ``args`` (command-line options and
arguments as returned by OptionParser.parse_args_).
.. _optionparser.parse_args: https://docs.python.org/library/optparse.html#parsing-arguments
The function should use any of the utility functions defined in ``beets.ui``.
Try running ``pydoc beets.ui`` to see what's available.
You can add command-line options to your new command using the ``parser`` member
of the ``Subcommand`` class, which is a ``CommonOptionsParser`` instance. Just
use it like you would a normal ``OptionParser`` in an independent script. Note
that it offers several methods to add common options: ``--album``, ``--path``
and ``--format``. This feature is versatile and extensively documented, try
``pydoc beets.ui.CommonOptionsParser`` for more information.
.. _plugin_events:
Listen for Events
~~~~~~~~~~~~~~~~~
Event handlers allow plugins to run code whenever something happens in beets'
operation. For instance, a plugin could write a log message every time an album
is successfully autotagged or update MPD's index whenever the database is
changed.
You can "listen" for events using ``BeetsPlugin.register_listener``. Here's an
example:
::
from beets.plugins import BeetsPlugin
def loaded():
print 'Plugin loaded!'
class SomePlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.register_listener('pluginload', loaded)
Note that if you want to access an attribute of your plugin (e.g. ``config`` or
``log``) you'll have to define a method and not a function. Here is the usual
registration process in this case:
::
from beets.plugins import BeetsPlugin
class SomePlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.register_listener('pluginload', self.loaded)
def loaded(self):
self._log.info('Plugin loaded!')
The events currently available are:
- ``pluginload``: called after all the plugins have been loaded after the
``beet`` command starts
- ``import``: called after a ``beet import`` command finishes (the ``lib``
keyword argument is a Library object; ``paths`` is a list of paths (strings)
that were imported)
- ``album_imported``: called with an ``Album`` object every time the ``import``
command finishes adding an album to the library. Parameters: ``lib``,
``album``
- ``album_removed``: called with an ``Album`` object every time an album is
removed from the library (even when its file is not deleted from disk).
- ``item_copied``: called with an ``Item`` object whenever its file is copied.
Parameters: ``item``, ``source`` path, ``destination`` path
- ``item_imported``: called with an ``Item`` object every time the importer adds
a singleton to the library (not called for full-album imports). Parameters:
``lib``, ``item``
- ``before_item_moved``: called with an ``Item`` object immediately before its
file is moved. Parameters: ``item``, ``source`` path, ``destination`` path
- ``item_moved``: called with an ``Item`` object whenever its file is moved.
Parameters: ``item``, ``source`` path, ``destination`` path
- ``item_linked``: called with an ``Item`` object whenever a symlink is created
for a file. Parameters: ``item``, ``source`` path, ``destination`` path
- ``item_hardlinked``: called with an ``Item`` object whenever a hardlink is
created for a file. Parameters: ``item``, ``source`` path, ``destination``
path
- ``item_reflinked``: called with an ``Item`` object whenever a reflink is
created for a file. Parameters: ``item``, ``source`` path, ``destination``
path
- ``item_removed``: called with an ``Item`` object every time an item (singleton
or album's part) is removed from the library (even when its file is not
deleted from disk).
- ``write``: called with an ``Item`` object, a ``path``, and a ``tags``
dictionary just before a file's metadata is written to disk (i.e., just before
the file on disk is opened). Event handlers may change the ``tags`` dictionary
to customize the tags that are written to the media file. Event handlers may
also raise a ``library.FileOperationError`` exception to abort the write
operation. Beets will catch that exception, print an error message and
continue.
- ``after_write``: called with an ``Item`` object after a file's metadata is
written to disk (i.e., just after the file on disk is closed).
- ``import_task_created``: called immediately after an import task is
initialized. Plugins can use this to, for example, change imported files of a
task before anything else happens. It's also possible to replace the task with
another task by returning a list of tasks. This list can contain zero or more
``ImportTask``. Returning an empty list will stop the task. Parameters:
``task`` (an ``ImportTask``) and ``session`` (an ``ImportSession``).
- ``import_task_start``: called when before an import task begins processing.
Parameters: ``task`` and ``session``.
- ``import_task_apply``: called after metadata changes have been applied in an
import task. This is called on the same thread as the UI, so use this
sparingly and only for tasks that can be done quickly. For most plugins, an
import pipeline stage is a better choice (see :ref:`plugin-stage`).
Parameters: ``task`` and ``session``.
- ``import_task_before_choice``: called after candidate search for an import
task before any decision is made about how/if to import or tag. Can be used to
present information about the task or initiate interaction with the user
before importing occurs. Return an importer action to take a specific action.
Only one handler may return a non-None result. Parameters: ``task`` and
``session``
- ``import_task_choice``: called after a decision has been made about an import
task. This event can be used to initiate further interaction with the user.
Use ``task.choice_flag`` to determine or change the action to be taken.
Parameters: ``task`` and ``session``.
- ``import_task_files``: called after an import task finishes manipulating the
filesystem (copying and moving files, writing metadata tags). Parameters:
``task`` and ``session``.
- ``library_opened``: called after beets starts up and initializes the main
Library object. Parameter: ``lib``.
- ``database_change``: a modification has been made to the library database. The
change might not be committed yet. Parameters: ``lib`` and ``model``.
- ``cli_exit``: called just before the ``beet`` command-line program exits.
Parameter: ``lib``.
- ``import_begin``: called just before a ``beet import`` session starts up.
Parameter: ``session``.
- ``trackinfo_received``: called after metadata for a track item has been
fetched from a data source, such as MusicBrainz. You can modify the tags that
the rest of the pipeline sees on a ``beet import`` operation or during later
adjustments, such as ``mbsync``. Slow handlers of the event can impact the
operation, since the event is fired for any fetched possible match ``before``
the user (or the autotagger machinery) gets to see the match. Parameter:
``info``.
- ``albuminfo_received``: like ``trackinfo_received``, the event indicates new
metadata for album items. The parameter is an ``AlbumInfo`` object instead of
a ``TrackInfo``. Parameter: ``info``.
- ``before_choose_candidate``: called before the user is prompted for a decision
during a ``beet import`` interactive session. Plugins can use this event for
:ref:`appending choices to the prompt <append_prompt_choices>` by returning a
list of ``PromptChoices``. Parameters: ``task`` and ``session``.
- ``mb_track_extract``: called after the metadata is obtained from MusicBrainz.
The parameter is a ``dict`` containing the tags retrieved from MusicBrainz for
a track. Plugins must return a new (potentially empty) ``dict`` with
additional ``field: value`` pairs, which the autotagger will apply to the
item, as flexible attributes if ``field`` is not a hardcoded field. Fields
already present on the track are overwritten. Parameter: ``data``
- ``mb_album_extract``: Like ``mb_track_extract``, but for album tags.
Overwrites tags set at the track level, if they have the same ``field``.
Parameter: ``data``
The included ``mpdupdate`` plugin provides an example use case for event
listeners.
Extend the Autotagger
~~~~~~~~~~~~~~~~~~~~~
Plugins can also enhance the functionality of the autotagger. For a
comprehensive example, try looking at the ``chroma`` plugin, which is included
with beets.
A plugin can extend three parts of the autotagger's process: the track distance
function, the album distance function, and the initial MusicBrainz search. The
distance functions determine how "good" a match is at the track and album
levels; the initial search controls which candidates are presented to the
matching algorithm. Plugins implement these extensions by implementing four
methods on the plugin class:
- ``track_distance(self, item, info)``: adds a component to the distance
function (i.e., the similarity metric) for individual tracks. ``item`` is the
track to be matched (an Item object) and ``info`` is the TrackInfo object that
is proposed as a match. Should return a ``(dist, dist_max)`` pair of floats
indicating the distance.
- ``album_distance(self, items, album_info, mapping)``: like the above, but
compares a list of items (representing an album) to an album-level MusicBrainz
entry. ``items`` is a list of Item objects; ``album_info`` is an AlbumInfo
object; and ``mapping`` is a dictionary that maps Items to their corresponding
TrackInfo objects.
- ``candidates(self, items, artist, album, va_likely)``: given a list of items
comprised by an album to be matched, return a list of ``AlbumInfo`` objects
for candidate albums to be compared and matched.
- ``item_candidates(self, item, artist, album)``: given a *singleton* item,
return a list of ``TrackInfo`` objects for candidate tracks to be compared and
matched.
- ``album_for_id(self, album_id)``: given an ID from user input or an album's
tags, return a candidate AlbumInfo object (or None).
- ``track_for_id(self, track_id)``: given an ID from user input or a file's
tags, return a candidate TrackInfo object (or None).
When implementing these functions, you may want to use the functions from the
``beets.autotag`` and ``beets.autotag.mb`` modules, both of which have somewhat
helpful docstrings.
Read Configuration Options
~~~~~~~~~~~~~~~~~~~~~~~~~~
Plugins can configure themselves using the ``config.yaml`` file. You can read
configuration values in two ways. The first is to use ``self.config`` within
your plugin class. This gives you a view onto the configuration values in a
section with the same name as your plugin's module. For example, if your plugin
is in ``greatplugin.py``, then ``self.config`` will refer to options under the
``greatplugin:`` section of the config file.
For example, if you have a configuration value called "foo", then users can put
this in their ``config.yaml``:
::
greatplugin:
foo: bar
To access this value, say ``self.config['foo'].get()`` at any point in your
plugin's code. The ``self.config`` object is a *view* as defined by the Confuse_
library.
.. _confuse: https://confuse.readthedocs.io/en/latest/
If you want to access configuration values *outside* of your plugin's section,
import the ``config`` object from the ``beets`` module. That is, just put ``from
beets import config`` at the top of your plugin and access values from there.
If your plugin provides configuration values for sensitive data (e.g.,
passwords, API keys, ...), you should add these to the config so they can be
redacted automatically when users dump their config. This can be done by setting
each value's ``redact`` flag, like so:
::
self.config['password'].redact = True
Add Path Format Functions and Fields
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Beets supports *function calls* in its path format syntax (see
:doc:`/reference/pathformat`). Beets includes a few built-in functions, but
plugins can register new functions by adding them to the ``template_funcs``
dictionary.
Here's an example:
::
class MyPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.template_funcs['initial'] = _tmpl_initial
def _tmpl_initial(text: str) -> str:
if text:
return text[0].upper()
else:
return u''
This plugin provides a function ``%initial`` to path templates where
``%initial{$artist}`` expands to the artist's initial (its capitalized first
character).
Plugins can also add template *fields*, which are computed values referenced as
``$name`` in templates. To add a new field, add a function that takes an
``Item`` object to the ``template_fields`` dictionary on the plugin object.
Here's an example that adds a ``$disc_and_track`` field:
::
class MyPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.template_fields['disc_and_track'] = _tmpl_disc_and_track
def _tmpl_disc_and_track(item: Item) -> str:
"""Expand to the disc number and track number if this is a
multi-disc release. Otherwise, just expands to the track
number.
"""
if item.disctotal > 1:
return f"{item.disc:02d}.{item.track:02d}"
else:
return f"{item.track:02d}"
With this plugin enabled, templates can reference ``$disc_and_track`` as they
can any standard metadata field.
This field works for *item* templates. Similarly, you can register *album*
template fields by adding a function accepting an ``Album`` argument to the
``album_template_fields`` dict.
Extend MediaFile
~~~~~~~~~~~~~~~~
MediaFile_ is the file tag abstraction layer that beets uses to make
cross-format metadata manipulation simple. Plugins can add fields to MediaFile
to extend the kinds of metadata that they can easily manage.
The ``MediaFile`` class uses ``MediaField`` descriptors to provide access to
file tags. If you have created a descriptor you can add it through your plugins
:py:meth:`beets.plugins.BeetsPlugin.add_media_field()` method.
.. _mediafile: https://mediafile.readthedocs.io/en/latest/
Here's an example plugin that provides a meaningless new field "foo":
::
class FooPlugin(BeetsPlugin):
def __init__(self):
field = mediafile.MediaField(
mediafile.MP3DescStorageStyle(u'foo'),
mediafile.StorageStyle(u'foo')
)
self.add_media_field('foo', field)
FooPlugin()
item = Item.from_path('/path/to/foo/tag.mp3')
assert item['foo'] == 'spam'
item['foo'] == 'ham'
item.write()
# The "foo" tag of the file is now "ham"
.. _plugin-stage:
Add Import Pipeline Stages
~~~~~~~~~~~~~~~~~~~~~~~~~~
Many plugins need to add high-latency operations to the import workflow. For
example, a plugin that fetches lyrics from the Web would, ideally, not block the
progress of the rest of the importer. Beets allows plugins to add stages to the
parallel import pipeline.
Each stage is run in its own thread. Plugin stages run after metadata changes
have been applied to a unit of music (album or track) and before file
manipulation has occurred (copying and moving files, writing tags to disk).
Multiple stages run in parallel but each stage processes only one task at a time
and each task is processed by only one stage at a time.
Plugins provide stages as functions that take two arguments: ``config`` and
``task``, which are ``ImportSession`` and ``ImportTask`` objects (both defined
in ``beets.importer``). Add such a function to the plugin's ``import_stages``
field to register it:
::
from beets.plugins import BeetsPlugin
class ExamplePlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.import_stages = [self.stage]
def stage(self, session, task):
print('Importing something!')
It is also possible to request your function to run early in the pipeline by
adding the function to the plugin's ``early_import_stages`` field instead:
::
self.early_import_stages = [self.stage]
.. _extend-query:
Extend the Query Syntax
~~~~~~~~~~~~~~~~~~~~~~~
You can add new kinds of queries to beets' :doc:`query syntax
</reference/query>`. There are two ways to add custom queries: using a prefix
and using a name. Prefix-based query extension can apply to *any* field, while
named queries are not associated with any field. For example, beets already
supports regular expression queries, which are indicated by a colon
prefix---plugins can do the same.
For either kind of query extension, define a subclass of the ``Query`` type from
the ``beets.dbcore.query`` module. Then:
- To define a prefix-based query, define a ``queries`` method in your plugin
class. Return from this method a dictionary mapping prefix strings to query
classes.
- To define a named query, defined dictionaries named either ``item_queries`` or
``album_queries``. These should map names to query types. So if you use ``{
"foo": FooQuery }``, then the query ``foo:bar`` will construct a query like
``FooQuery("bar")``.
For prefix-based queries, you will want to extend ``FieldQuery``, which
implements string comparisons on fields. To use it, create a subclass inheriting
from that class and override the ``value_match`` class method. (Remember the
``@classmethod`` decorator!) The following example plugin declares a query using
the ``@`` prefix to delimit exact string matches. The plugin will be used if we
issue a command like ``beet ls @something`` or ``beet ls artist:@something``:
::
from beets.plugins import BeetsPlugin
from beets.dbcore import FieldQuery
class ExactMatchQuery(FieldQuery):
@classmethod
def value_match(self, pattern, val):
return pattern == val
class ExactMatchPlugin(BeetsPlugin):
def queries(self):
return {
'@': ExactMatchQuery
}
Flexible Field Types
~~~~~~~~~~~~~~~~~~~~
If your plugin uses flexible fields to store numbers or other non-string values,
you can specify the types of those fields. A rating plugin, for example, might
want to declare that the ``rating`` field should have an integer type:
::
from beets.plugins import BeetsPlugin
from beets.dbcore import types
class RatingPlugin(BeetsPlugin):
item_types = {'rating': types.INTEGER}
@property
def album_types(self):
return {'rating': types.INTEGER}
A plugin may define two attributes: ``item_types`` and ``album_types``. Each of
those attributes is a dictionary mapping a flexible field name to a type
instance. You can find the built-in types in the ``beets.dbcore.types`` and
``beets.library`` modules or implement your own type by inheriting from the
``Type`` class.
Specifying types has several advantages:
- Code that accesses the field like ``item['my_field']`` gets the right type
(instead of just a string).
- You can use advanced queries (like :ref:`ranges <numericquery>`) from the
command line.
- User input for flexible fields may be validated and converted.
- Items missing the given field can use an appropriate null value for querying
and sorting purposes.
.. _plugin-logging:
Logging
~~~~~~~
Each plugin object has a ``_log`` attribute, which is a ``Logger`` from the
`standard Python logging module`_. The logger is set up to `PEP 3101`_,
str.format-style string formatting. So you can write logging calls like this:
::
self._log.debug(u'Processing {0.title} by {0.artist}', item)
.. _pep 3101: https://www.python.org/dev/peps/pep-3101/
.. _standard python logging module: https://docs.python.org/2/library/logging.html
When beets is in verbose mode, plugin messages are prefixed with the plugin name
to make them easier to see.
Which messages will be logged depends on the logging level and the action
performed:
- Inside import stages and event handlers, the default is ``WARNING`` messages
and above.
- Everywhere else, the default is ``INFO`` or above.
The verbosity can be increased with ``--verbose`` (``-v``) flags: each flags
lowers the level by a notch. That means that, with a single ``-v`` flag, event
handlers won't have their ``DEBUG`` messages displayed, but command functions
(for example) will. With ``-vv`` on the command line, ``DEBUG`` messages will be
displayed everywhere.
This addresses a common pattern where plugins need to use the same code for a
command and an import stage, but the command needs to print more messages than
the import stage. (For example, you'll want to log "found lyrics for this song"
when you're run explicitly as a command, but you don't want to noisily interrupt
the importer interface when running automatically.)
.. _append_prompt_choices:
Append Prompt Choices
~~~~~~~~~~~~~~~~~~~~~
Plugins can also append choices to the prompt presented to the user during an
import session.
To do so, add a listener for the ``before_choose_candidate`` event, and return a
list of ``PromptChoices`` that represent the additional choices that your plugin
shall expose to the user:
::
from beets.plugins import BeetsPlugin
from beets.ui.commands import PromptChoice
class ExamplePlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.register_listener('before_choose_candidate',
self.before_choose_candidate_event)
def before_choose_candidate_event(self, session, task):
return [PromptChoice('p', 'Print foo', self.foo),
PromptChoice('d', 'Do bar', self.bar)]
def foo(self, session, task):
print('User has chosen "Print foo"!')
def bar(self, session, task):
print('User has chosen "Do bar"!')
The previous example modifies the standard prompt:
::
# selection (default 1), Skip, Use as-is, as Tracks, Group albums,
Enter search, enter Id, aBort?
by appending two additional options (``Print foo`` and ``Do bar``):
::
# selection (default 1), Skip, Use as-is, as Tracks, Group albums,
Enter search, enter Id, aBort, Print foo, Do bar?
If the user selects a choice, the ``callback`` attribute of the corresponding
``PromptChoice`` will be called. It is the responsibility of the plugin to check
for the status of the import session and decide the choices to be appended: for
example, if a particular choice should only be presented if the album has no
candidates, the relevant checks against ``task.candidates`` should be performed
inside the plugin's ``before_choose_candidate_event`` accordingly.
Please make sure that the short letter for each of the choices provided by the
plugin is not already in use: the importer will emit a warning and discard all
but one of the choices using the same letter, giving priority to the core
importer prompt choices. As a reference, the following characters are used by
the choices on the core importer prompt, and hence should not be used: ``a``,
``s``, ``u``, ``t``, ``g``, ``e``, ``i``, ``b``.
Additionally, the callback function can optionally specify the next action to be
performed by returning a ``importer.Action`` value. It may also return a
``autotag.Proposal`` value to update the set of current proposals to be
considered.

View file

@ -0,0 +1,107 @@
Extending the Autotagger
========================
.. currentmodule:: beets.metadata_plugins
Beets supports **metadata source plugins**, which allow it to fetch and match
metadata from external services (such as Spotify, Discogs, or Deezer). This
guide explains how to build your own metadata source plugin by extending the
:py:class:`MetadataSourcePlugin`.
These plugins integrate directly with the autotagger, providing candidate
metadata during lookups. To implement one, you must subclass
:py:class:`MetadataSourcePlugin` and implement its abstract methods.
Overview
--------
Creating a metadata source plugin is very similar to writing a standard plugin
(see :ref:`basic-plugin-setup`). The main difference is that your plugin must:
1. Subclass :py:class:`MetadataSourcePlugin`.
2. Implement all required abstract methods.
Here`s a minimal example:
.. code-block:: python
# beetsplug/myawesomeplugin.py
from typing import Sequence
from beets.autotag.hooks import Item
from beets.metadata_plugin import MetadataSourcePlugin
class MyAwesomePlugin(MetadataSourcePlugin):
def candidates(
self,
items: Sequence[Item],
artist: str,
album: str,
va_likely: bool,
): ...
def item_candidates(self, item: Item, artist: str, title: str): ...
def track_for_id(self, track_id: str): ...
def album_for_id(self, album_id: str): ...
Each metadata source plugin automatically gets a unique identifier. You can
access this identifier using the :py:meth:`~MetadataSourcePlugin.data_source`
class property to tell plugins apart.
Metadata lookup
---------------
When beets runs the autotagger, it queries **all enabled metadata source
plugins** for potential matches:
- For **albums**, it calls :py:meth:`~MetadataSourcePlugin.candidates`.
- For **singletons**, it calls :py:meth:`~MetadataSourcePlugin.item_candidates`.
The results are combined and scored. By default, candidate ranking is handled
automatically by the beets core, but you can customize weighting by overriding:
- :py:meth:`~MetadataSourcePlugin.album_distance`
- :py:meth:`~MetadataSourcePlugin.track_distance`
This is optional, if not overridden, both methods return a constant distance of
`0.5`.
ID-based lookups
----------------
Your plugin must also define:
- :py:meth:`~MetadataSourcePlugin.album_for_id` — fetch album metadata by ID.
- :py:meth:`~MetadataSourcePlugin.track_for_id` — fetch track metadata by ID.
IDs are expected to be strings. If your source uses specific formats, consider
contributing an extractor regex to the core module:
:py:mod:`beets.util.id_extractors`.
Best practices
--------------
Beets already ships with several metadata source plugins. Studying these
implementations can help you follow conventions and avoid pitfalls. Good
starting points include:
- ``spotify``
- ``deezer``
- ``discogs``
Migration guidance
------------------
Older metadata plugins that extend :py:class:`beets.plugins.BeetsPlugin` should
be migrated to :py:class:`MetadataSourcePlugin`. Legacy support will be removed
in **beets v3.0.0**.
.. seealso::
- :py:mod:`beets.autotag`
- :py:mod:`beets.metadata_plugins`
- :ref:`autotagger_extensions`
- :ref:`using-the-auto-tagger`

View file

@ -0,0 +1,54 @@
.. _add_subcommands:
Add Commands to the CLI
=======================
Plugins can add new subcommands to the ``beet`` command-line interface. Define
the plugin class' ``commands()`` method to return a list of ``Subcommand``
objects. (The ``Subcommand`` class is defined in the ``beets.ui`` module.)
Here's an example plugin that adds a simple command:
.. code-block:: python
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
my_super_command = Subcommand("super", help="do something super")
def say_hi(lib, opts, args):
print("Hello everybody! I'm a plugin!")
my_super_command.func = say_hi
class SuperPlug(BeetsPlugin):
def commands(self):
return [my_super_command]
To make a subcommand, invoke the constructor like so: ``Subcommand(name, parser,
help, aliases)``. The ``name`` parameter is the only required one and should
just be the name of your command. ``parser`` can be an `OptionParser instance`_,
but it defaults to an empty parser (you can extend it later). ``help`` is a
description of your command, and ``aliases`` is a list of shorthand versions of
your command name.
.. _optionparser instance: https://docs.python.org/library/optparse.html
You'll need to add a function to your command by saying ``mycommand.func =
myfunction``. This function should take the following parameters: ``lib`` (a
beets ``Library`` object) and ``opts`` and ``args`` (command-line options and
arguments as returned by OptionParser.parse_args_).
.. _optionparser.parse_args: https://docs.python.org/library/optparse.html#parsing-arguments
The function should use any of the utility functions defined in ``beets.ui``.
Try running ``pydoc beets.ui`` to see what's available.
You can add command-line options to your new command using the ``parser`` member
of the ``Subcommand`` class, which is a ``CommonOptionsParser`` instance. Just
use it like you would a normal ``OptionParser`` in an independent script. Note
that it offers several methods to add common options: ``--album``, ``--path``
and ``--format``. This feature is versatile and extensively documented, try
``pydoc beets.ui.CommonOptionsParser`` for more information.

199
docs/dev/plugins/events.rst Normal file
View file

@ -0,0 +1,199 @@
.. _plugin_events:
Listen for Events
=================
.. currentmodule:: beets.plugins
Event handlers allow plugins to hook into whenever something happens in beets'
operations. For instance, a plugin could write a log message every time an album
is successfully autotagged or update MPD's index whenever the database is
changed.
You can "listen" for events using :py:meth:`BeetsPlugin.register_listener`.
Here's an example:
.. code-block:: python
from beets.plugins import BeetsPlugin
def loaded():
print("Plugin loaded!")
class SomePlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.register_listener("pluginload", loaded)
Note that if you want to access an attribute of your plugin (e.g. ``config`` or
``log``) you'll have to define a method and not a function. Here is the usual
registration process in this case:
.. code-block:: python
from beets.plugins import BeetsPlugin
class SomePlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.register_listener("pluginload", self.loaded)
def loaded(self):
self._log.info("Plugin loaded!")
.. rubric:: Plugin Events
``pluginload``
:Parameters: (none)
:Description: Called after all plugins have been loaded after the ``beet``
command starts.
``import``
:Parameters: ``lib`` (|Library|), ``paths`` (list of path strings)
:Description: Called after the ``import`` command finishes.
``album_imported``
:Parameters: ``lib`` (|Library|), ``album`` (|Album|)
:Description: Called every time the importer finishes adding an album to the
library.
``album_removed``
:Parameters: ``lib`` (|Library|), ``album`` (|Album|)
:Description: Called every time an album is removed from the library (even
when its files are not deleted from disk).
``item_copied``
:Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path)
:Description: Called whenever an item file is copied.
``item_imported``
:Parameters: ``lib`` (|Library|), ``item`` (|Item|)
:Description: Called every time the importer adds a singleton to the library
(not called for full-album imports).
``before_item_imported``
:Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path)
:Description: Called with an ``Item`` object immediately before it is
imported.
``before_item_moved``
:Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path)
:Description: Called with an ``Item`` object immediately before its file is
moved.
``item_moved``
:Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path)
:Description: Called with an ``Item`` object whenever its file is moved.
``item_linked``
:Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path)
:Description: Called with an ``Item`` object whenever a symlink is created
for a file.
``item_hardlinked``
:Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path)
:Description: Called with an ``Item`` object whenever a hardlink is created
for a file.
``item_reflinked``
:Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path)
:Description: Called with an ``Item`` object whenever a reflink is created
for a file.
``item_removed``
:Parameters: ``item`` (|Item|)
:Description: Called with an ``Item`` object every time an item (singleton
or part of an album) is removed from the library (even when its file is
not deleted from disk).
``write``
:Parameters: ``item`` (|Item|), ``path`` (path), ``tags`` (dict)
:Description: Called just before a file's metadata is written to disk.
Handlers may modify ``tags`` or raise ``library.FileOperationError`` to
abort.
``after_write``
:Parameters: ``item`` (|Item|)
:Description: Called after a file's metadata is written to disk.
``import_task_created``
:Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|)
:Description: Called immediately after an import task is initialized. May
return a list (possibly empty) of replacement tasks.
``import_task_start``
:Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|)
:Description: Called before an import task begins processing.
``import_task_apply``
:Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|)
:Description: Called after metadata changes have been applied in an import
task (on the UI thread; keep fast). Prefer a pipeline stage otherwise
(see :ref:`plugin-stage`).
``import_task_before_choice``
:Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|)
:Description: Called after candidate search and before deciding how to
import. May return an importer action (only one handler may return
non-None).
``import_task_choice``
:Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|)
:Description: Called after a decision has been made about an import task.
Use ``task.choice_flag`` to inspect or change the action.
``import_task_files``
:Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|)
:Description: Called after filesystem manipulation (copy/move/write) for an
import task.
``library_opened``
:Parameters: ``lib`` (|Library|)
:Description: Called after beets starts and initializes the main Library
object.
``database_change``
:Parameters: ``lib`` (|Library|), ``model`` (|Model|)
:Description: A modification has been made to the library database (may not
yet be committed).
``cli_exit``
:Parameters: ``lib`` (|Library|)
:Description: Called just before the ``beet`` command-line program exits.
``import_begin``
:Parameters: ``session`` (|ImportSession|)
:Description: Called just before a ``beet import`` session starts.
``trackinfo_received``
:Parameters: ``info`` (|TrackInfo|)
:Description: Called after metadata for a track is fetched (e.g., from
MusicBrainz). Handlers can modify the tags seen by later pipeline stages
or adjustments (e.g., ``mbsync``).
``albuminfo_received``
:Parameters: ``info`` (|AlbumInfo|)
:Description: Like ``trackinfo_received`` but for album-level metadata.
``before_choose_candidate``
:Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|)
:Description: Called before prompting the user during interactive import.
May return a list of ``PromptChoices`` to append to the prompt (see
:ref:`append_prompt_choices`).
``mb_track_extract``
:Parameters: ``data`` (dict)
:Description: Called after metadata is obtained from MusicBrainz for a
track. Must return a (possibly empty) dict of additional ``field:
value`` pairs to apply (overwriting existing fields).
``mb_album_extract``
:Parameters: ``data`` (dict)
:Description: Like ``mb_track_extract`` but for album tags. Overwrites tags
set at the track level with the same field.
The included ``mpdupdate`` plugin provides an example use case for event
listeners.

103
docs/dev/plugins/index.rst Normal file
View file

@ -0,0 +1,103 @@
Plugin Development
==================
Beets plugins are Python modules or packages that extend the core functionality
of beets. The plugin system is designed to be flexible, allowing developers to
add virtually any type of features to beets.
For instance you can create plugins that add new commands to the command-line
interface, listen for events in the beets lifecycle or extend the autotagger
with new metadata sources.
.. _basic-plugin-setup:
Basic Plugin Setup
------------------
A beets plugin is just a Python module or package inside the ``beetsplug``
namespace [1]_ package. To create the basic plugin layout, create a directory
called ``beetsplug`` and add either your plugin module:
.. code-block:: shell
beetsplug/
└── myawesomeplugin.py
or your plugin subpackage
.. code-block:: shell
beetsplug/
└── myawesomeplugin/
├── __init__.py
└── myawesomeplugin.py
.. attention::
You do not need to add an ``__init__.py`` file to the ``beetsplug``
directory. Python treats your plugin as a namespace package automatically,
thus we do not depend on ``pkgutil``-based setup in the ``__init__.py`` file
anymore.
The meat of your plugin goes in ``myawesomeplugin.py``. Every plugin has to
extend the :class:`beets.plugins.BeetsPlugin` abstract base class [2]_ . For
instance, a minimal plugin without any functionality would look like this:
.. code-block:: python
# beetsplug/myawesomeplugin.py
from beets.plugins import BeetsPlugin
class MyAwesomePlugin(BeetsPlugin):
pass
To use your new plugin, you need to package [3]_ your plugin and install it into
your ``beets`` (virtual) environment. To enable your plugin, add it it to the
beets configuration
.. code-block:: yaml
# config.yaml
plugins:
- myawesomeplugin
and you're good to go!
.. [1] Check out `this article`_ and `this Stack Overflow question`_ if you
haven't heard about namespace packages.
.. [2] Abstract base classes allow us to define a contract which any plugin must
follow. This is a common paradigm in object-oriented programming, and it
helps to ensure that plugins are implemented in a consistent way. For more
information, see for example pep-3119_.
.. [3] There are a variety of packaging tools available for python, for example
you can use poetry_, setuptools_ or hatchling_.
.. _hatchling: https://hatch.pypa.io/latest/config/build/#build-system
.. _pep-3119: https://peps.python.org/pep-3119/#rationale
.. _poetry: https://python-poetry.org/docs/pyproject/#packages
.. _setuptools: https://setuptools.pypa.io/en/latest/userguide/package_discovery.html#finding-simple-packages
.. _this article: https://realpython.com/python-namespace-package/#setting-up-some-namespace-packages
.. _this stack overflow question: https://stackoverflow.com/a/27586272/9582674
More information
----------------
For more information on writing plugins, feel free to check out the following
resources:
.. toctree::
:maxdepth: 3
:includehidden:
commands
events
autotagger
other/index

View file

@ -0,0 +1,36 @@
Read Configuration Options
==========================
Plugins can configure themselves using the ``config.yaml`` file. You can read
configuration values in two ways. The first is to use ``self.config`` within
your plugin class. This gives you a view onto the configuration values in a
section with the same name as your plugin's module. For example, if your plugin
is in ``greatplugin.py``, then ``self.config`` will refer to options under the
``greatplugin:`` section of the config file.
For example, if you have a configuration value called "foo", then users can put
this in their ``config.yaml``:
::
greatplugin:
foo: bar
To access this value, say ``self.config['foo'].get()`` at any point in your
plugin's code. The ``self.config`` object is a *view* as defined by the Confuse_
library.
.. _confuse: https://confuse.readthedocs.io/en/latest/
If you want to access configuration values *outside* of your plugin's section,
import the ``config`` object from the ``beets`` module. That is, just put ``from
beets import config`` at the top of your plugin and access values from there.
If your plugin provides configuration values for sensitive data (e.g.,
passwords, API keys, ...), you should add these to the config so they can be
redacted automatically when users dump their config. This can be done by setting
each value's ``redact`` flag, like so:
::
self.config['password'].redact = True

View file

@ -0,0 +1,35 @@
Flexible Field Types
====================
If your plugin uses flexible fields to store numbers or other non-string values,
you can specify the types of those fields. A rating plugin, for example, might
want to declare that the ``rating`` field should have an integer type:
.. code-block:: python
from beets.plugins import BeetsPlugin
from beets.dbcore import types
class RatingPlugin(BeetsPlugin):
item_types = {"rating": types.INTEGER}
@property
def album_types(self):
return {"rating": types.INTEGER}
A plugin may define two attributes: ``item_types`` and ``album_types``. Each of
those attributes is a dictionary mapping a flexible field name to a type
instance. You can find the built-in types in the ``beets.dbcore.types`` and
``beets.library`` modules or implement your own type by inheriting from the
``Type`` class.
Specifying types has several advantages:
- Code that accesses the field like ``item['my_field']`` gets the right type
(instead of just a string).
- You can use advanced queries (like :ref:`ranges <numericquery>`) from the
command line.
- User input for flexible fields may be validated and converted.
- Items missing the given field can use an appropriate null value for querying
and sorting purposes.

View file

@ -0,0 +1,88 @@
.. _plugin-stage:
Add Import Pipeline Stages
==========================
Many plugins need to add high-latency operations to the import workflow. For
example, a plugin that fetches lyrics from the Web would, ideally, not block the
progress of the rest of the importer. Beets allows plugins to add stages to the
parallel import pipeline.
Each stage is run in its own thread. Plugin stages run after metadata changes
have been applied to a unit of music (album or track) and before file
manipulation has occurred (copying and moving files, writing tags to disk).
Multiple stages run in parallel but each stage processes only one task at a time
and each task is processed by only one stage at a time.
Plugins provide stages as functions that take two arguments: ``config`` and
``task``, which are ``ImportSession`` and ``ImportTask`` objects (both defined
in ``beets.importer``). Add such a function to the plugin's ``import_stages``
field to register it:
.. code-block:: python
from beets.importer import ImportSession, ImportTask
from beets.plugins import BeetsPlugin
class ExamplePlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.import_stages = [self.stage]
def stage(self, session: ImportSession, task: ImportTask):
print("Importing something!")
It is also possible to request your function to run early in the pipeline by
adding the function to the plugin's ``early_import_stages`` field instead:
.. code-block:: python
self.early_import_stages = [self.stage]
.. _extend-query:
Extend the Query Syntax
-----------------------
You can add new kinds of queries to beets' :doc:`query syntax
</reference/query>`. There are two ways to add custom queries: using a prefix
and using a name. Prefix-based query extension can apply to *any* field, while
named queries are not associated with any field. For example, beets already
supports regular expression queries, which are indicated by a colon
prefix---plugins can do the same.
For either kind of query extension, define a subclass of the ``Query`` type from
the ``beets.dbcore.query`` module. Then:
- To define a prefix-based query, define a ``queries`` method in your plugin
class. Return from this method a dictionary mapping prefix strings to query
classes.
- To define a named query, defined dictionaries named either ``item_queries`` or
``album_queries``. These should map names to query types. So if you use ``{
"foo": FooQuery }``, then the query ``foo:bar`` will construct a query like
``FooQuery("bar")``.
For prefix-based queries, you will want to extend ``FieldQuery``, which
implements string comparisons on fields. To use it, create a subclass inheriting
from that class and override the ``value_match`` class method. (Remember the
``@classmethod`` decorator!) The following example plugin declares a query using
the ``@`` prefix to delimit exact string matches. The plugin will be used if we
issue a command like ``beet ls @something`` or ``beet ls artist:@something``:
.. code-block:: python
from beets.plugins import BeetsPlugin
from beets.dbcore import FieldQuery
class ExactMatchQuery(FieldQuery):
@classmethod
def value_match(self, pattern, val):
return pattern == val
class ExactMatchPlugin(BeetsPlugin):
def queries(self):
return {"@": ExactMatchQuery}

View file

@ -0,0 +1,16 @@
Further Reading
===============
For more information on writing plugins, feel free to check out the following
resources:
.. toctree::
:maxdepth: 2
config
templates
mediafile
import
fields
logging
prompts

View file

@ -0,0 +1,38 @@
.. _plugin-logging:
Logging
=======
Each plugin object has a ``_log`` attribute, which is a ``Logger`` from the
`standard Python logging module`_. The logger is set up to `PEP 3101`_,
str.format-style string formatting. So you can write logging calls like this:
.. code-block:: python
self._log.debug("Processing {0.title} by {0.artist}", item)
.. _pep 3101: https://www.python.org/dev/peps/pep-3101/
.. _standard python logging module: https://docs.python.org/2/library/logging.html
When beets is in verbose mode, plugin messages are prefixed with the plugin name
to make them easier to see.
Which messages will be logged depends on the logging level and the action
performed:
- Inside import stages and event handlers, the default is ``WARNING`` messages
and above.
- Everywhere else, the default is ``INFO`` or above.
The verbosity can be increased with ``--verbose`` (``-v``) flags: each flags
lowers the level by a notch. That means that, with a single ``-v`` flag, event
handlers won't have their ``DEBUG`` messages displayed, but command functions
(for example) will. With ``-vv`` on the command line, ``DEBUG`` messages will be
displayed everywhere.
This addresses a common pattern where plugins need to use the same code for a
command and an import stage, but the command needs to print more messages than
the import stage. (For example, you'll want to log "found lyrics for this song"
when you're run explicitly as a command, but you don't want to noisily interrupt
the importer interface when running automatically.)

View file

@ -0,0 +1,32 @@
Extend MediaFile
================
MediaFile_ is the file tag abstraction layer that beets uses to make
cross-format metadata manipulation simple. Plugins can add fields to MediaFile
to extend the kinds of metadata that they can easily manage.
The ``MediaFile`` class uses ``MediaField`` descriptors to provide access to
file tags. If you have created a descriptor you can add it through your plugins
:py:meth:`beets.plugins.BeetsPlugin.add_media_field` method.
.. _mediafile: https://mediafile.readthedocs.io/en/latest/
Here's an example plugin that provides a meaningless new field "foo":
.. code-block:: python
class FooPlugin(BeetsPlugin):
def __init__(self):
field = mediafile.MediaField(
mediafile.MP3DescStorageStyle("foo"), mediafile.StorageStyle("foo")
)
self.add_media_field("foo", field)
FooPlugin()
item = Item.from_path("/path/to/foo/tag.mp3")
assert item["foo"] == "spam"
item["foo"] == "ham"
item.write()
# The "foo" tag of the file is now "ham"

View file

@ -0,0 +1,69 @@
.. _append_prompt_choices:
Append Prompt Choices
=====================
Plugins can also append choices to the prompt presented to the user during an
import session.
To do so, add a listener for the ``before_choose_candidate`` event, and return a
list of ``PromptChoices`` that represent the additional choices that your plugin
shall expose to the user:
.. code-block:: python
from beets.plugins import BeetsPlugin
from beets.ui.commands import PromptChoice
class ExamplePlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.register_listener(
"before_choose_candidate", self.before_choose_candidate_event
)
def before_choose_candidate_event(self, session, task):
return [
PromptChoice("p", "Print foo", self.foo),
PromptChoice("d", "Do bar", self.bar),
]
def foo(self, session, task):
print('User has chosen "Print foo"!')
def bar(self, session, task):
print('User has chosen "Do bar"!')
The previous example modifies the standard prompt:
.. code-block:: shell
# selection (default 1), Skip, Use as-is, as Tracks, Group albums,
Enter search, enter Id, aBort?
by appending two additional options (``Print foo`` and ``Do bar``):
.. code-block:: shell
# selection (default 1), Skip, Use as-is, as Tracks, Group albums,
Enter search, enter Id, aBort, Print foo, Do bar?
If the user selects a choice, the ``callback`` attribute of the corresponding
``PromptChoice`` will be called. It is the responsibility of the plugin to check
for the status of the import session and decide the choices to be appended: for
example, if a particular choice should only be presented if the album has no
candidates, the relevant checks against ``task.candidates`` should be performed
inside the plugin's ``before_choose_candidate_event`` accordingly.
Please make sure that the short letter for each of the choices provided by the
plugin is not already in use: the importer will emit a warning and discard all
but one of the choices using the same letter, giving priority to the core
importer prompt choices. As a reference, the following characters are used by
the choices on the core importer prompt, and hence should not be used: ``a``,
``s``, ``u``, ``t``, ``g``, ``e``, ``i``, ``b``.
Additionally, the callback function can optionally specify the next action to be
performed by returning a ``importer.Action`` value. It may also return a
``autotag.Proposal`` value to update the set of current proposals to be
considered.

View file

@ -0,0 +1,57 @@
Add Path Format Functions and Fields
====================================
Beets supports *function calls* in its path format syntax (see
:doc:`/reference/pathformat`). Beets includes a few built-in functions, but
plugins can register new functions by adding them to the ``template_funcs``
dictionary.
Here's an example:
.. code-block:: python
class MyPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.template_funcs["initial"] = _tmpl_initial
def _tmpl_initial(text: str) -> str:
if text:
return text[0].upper()
else:
return ""
This plugin provides a function ``%initial`` to path templates where
``%initial{$artist}`` expands to the artist's initial (its capitalized first
character).
Plugins can also add template *fields*, which are computed values referenced as
``$name`` in templates. To add a new field, add a function that takes an
``Item`` object to the ``template_fields`` dictionary on the plugin object.
Here's an example that adds a ``$disc_and_track`` field:
.. code-block:: python
class MyPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.template_fields["disc_and_track"] = _tmpl_disc_and_track
def _tmpl_disc_and_track(item: Item) -> str:
"""Expand to the disc number and track number if this is a
multi-disc release. Otherwise, just expands to the track
number.
"""
if item.disctotal > 1:
return "%02i.%02i" % (item.disc, item.track)
else:
return "%02i" % (item.track)
With this plugin enabled, templates can reference ``$disc_and_track`` as they
can any standard metadata field.
This field works for *item* templates. Similarly, you can register *album*
template fields by adding a function accepting an ``Album`` argument to the
``album_template_fields`` dict.

View file

@ -1,3 +1,5 @@
.. _using-the-auto-tagger:
Using the Auto-Tagger
=====================

View file

@ -5,7 +5,7 @@ Plugins extend beets' core functionality. They add new commands, fetch
additional data during import, provide new metadata sources, and much more. If
beets by itself doesn't do what you want it to, you may just need to enable a
plugin---or, if you want to do something new, :doc:`writing a plugin
</dev/plugins>` is easy if you know a little Python.
</dev/plugins/index>` is easy if you know a little Python.
.. _using-plugins:

View file

@ -289,4 +289,4 @@ constructs include:
The :doc:`/plugins/inline` lets you define template fields in your beets
configuration file using Python snippets. And for more advanced processing, you
can go all-in and write a dedicated plugin to register your own fields and
functions (see :ref:`writing-plugins`).
functions (see :ref:`basic-plugin-setup`).

40
poetry.lock generated
View file

@ -696,28 +696,29 @@ files = [
[[package]]
name = "docstrfmt"
version = "1.10.0"
version = "1.11.1"
description = "docstrfmt: A formatter for Sphinx flavored reStructuredText."
optional = false
python-versions = "<4,>=3.8"
python-versions = ">=3.9"
files = [
{file = "docstrfmt-1.10.0-py3-none-any.whl", hash = "sha256:a34ef6f3d8ab3233a7d0b3d1c2f3c66f8acbb3917df5ed2f3e34c1629ac29cef"},
{file = "docstrfmt-1.10.0.tar.gz", hash = "sha256:9da96e71552937f4b49ae2d6ab1c118ffa8ad6968082e6b8fd978b01d1bc0066"},
{file = "docstrfmt-1.11.1-py3-none-any.whl", hash = "sha256:6782d8663321c3a7c40be08a36fbcb1ea9e46d1efba85411ba807d97f384871a"},
{file = "docstrfmt-1.11.1.tar.gz", hash = "sha256:d41e19d6c5d524cc7f8ff6cbfecb8762d77e696b9fe4f5057269051fb966fc80"},
]
[package.dependencies]
black = "==24.*"
click = "==8.*"
docutils = "==0.20.*"
libcst = "==1.*"
platformdirs = "==4.*"
sphinx = ">=7,<9"
tabulate = "==0.9.*"
toml = "==0.10.*"
black = ">=24"
click = ">=8"
docutils = ">=0.20"
libcst = ">=1"
platformdirs = ">=4"
roman = "*"
sphinx = ">=7"
tabulate = ">=0.9"
toml = {version = ">=0.10", markers = "python_version < \"3.11\""}
[package.extras]
ci = ["coveralls"]
d = ["aiohttp (==3.*)"]
d = ["aiohttp (>=3)"]
dev = ["docstrfmt[lint]", "docstrfmt[test]", "packaging"]
lint = ["pre-commit", "ruff (>=0.0.292)"]
test = ["pytest", "pytest-aiohttp"]
@ -2921,6 +2922,17 @@ urllib3 = ">=1.25.10,<3.0"
[package.extras]
tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-PyYAML", "types-requests"]
[[package]]
name = "roman"
version = "5.1"
description = "Integer to Roman numerals converter"
optional = false
python-versions = ">=3.9"
files = [
{file = "roman-5.1-py3-none-any.whl", hash = "sha256:bf595d8a9bc4a8e8b1dfa23e1d4def0251b03b494786df6b8c3d3f1635ce285a"},
{file = "roman-5.1.tar.gz", hash = "sha256:3a86572e9bc9183e771769601189e5fa32f1620ffeceebb9eca836affb409986"},
]
[[package]]
name = "ruff"
version = "0.12.3"
@ -3617,4 +3629,4 @@ web = ["flask", "flask-cors"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.9,<4"
content-hash = "daa6c3c2b5bee3180f74f4186bb29ee1ad825870b5b9f6c2b743fcaa61b34c8c"
content-hash = "faea27878ce1ca3f1335fd83e027b289351c51c73550bda72bf501a9c82166f7"

View file

@ -100,7 +100,7 @@ requests_oauthlib = "*"
responses = ">=0.3.0"
[tool.poetry.group.lint.dependencies]
docstrfmt = ">=1.10.0"
docstrfmt = ">=1.11.1"
ruff = ">=0.6.4"
sphinx-lint = ">=1.0.0"
@ -196,7 +196,8 @@ cmd = "mypy"
[tool.poe.tasks.docs]
help = "Build documentation"
cmd = "make -C docs html"
args = [{ name = "COMMANDS", positional = true, multiple = true, default = "html" }]
cmd = "make -C docs $COMMANDS"
[tool.poe.tasks.format]
help = "Format the codebase"
@ -212,7 +213,7 @@ cmd = "ruff check"
[tool.poe.tasks.lint-docs]
help = "Lint the documentation"
shell = "sphinx-lint --enable all $(git ls-files '*.rst')"
shell = "sphinx-lint --enable all --disable default-role $(git ls-files '*.rst')"
[tool.poe.tasks.update-dependencies]
help = "Update dependencies to their latest versions."