mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 08:39:17 +01:00
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:
commit
55667fa1e8
31 changed files with 970 additions and 739 deletions
|
|
@ -67,3 +67,7 @@ ab5acaabb3cd24c482adb7fa4800c89fd6a2f08d
|
||||||
2fccf64efe82851861e195b521b14680b480a42a
|
2fccf64efe82851861e195b521b14680b480a42a
|
||||||
# Do not use explicit indices for logging args when not needed
|
# Do not use explicit indices for logging args when not needed
|
||||||
d93ddf8dd43e4f9ed072a03829e287c78d2570a2
|
d93ddf8dd43e4f9ed072a03829e287c78d2570a2
|
||||||
|
# Moved dev docs
|
||||||
|
07549ed896d9649562d40b75cd30702e6fa6e975
|
||||||
|
# Moved plugin docs Further Reading chapter
|
||||||
|
33f1a5d0bef8ca08be79ee7a0d02a018d502680d
|
||||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
|
|
@ -143,4 +143,4 @@ jobs:
|
||||||
run: poe lint-docs
|
run: poe lint-docs
|
||||||
|
|
||||||
- name: Build docs
|
- name: Build docs
|
||||||
run: poe docs -e 'SPHINXOPTS=--fail-on-warning --keep-going'
|
run: poe docs -- -e 'SPHINXOPTS=--fail-on-warning --keep-going'
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ implemented as plugins.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
import inspect
|
|
||||||
import re
|
import re
|
||||||
import warnings
|
import warnings
|
||||||
from typing import TYPE_CHECKING, Generic, Literal, Sequence, TypedDict, TypeVar
|
from typing import TYPE_CHECKING, Generic, Literal, Sequence, TypedDict, TypeVar
|
||||||
|
|
@ -421,13 +420,3 @@ class SearchApiMetadataSourcePlugin(
|
||||||
query = unidecode.unidecode(query)
|
query = unidecode.unidecode(query)
|
||||||
|
|
||||||
return 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)
|
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,21 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
|
||||||
early_import_stages: list[ImportStageFunc]
|
early_import_stages: list[ImportStageFunc]
|
||||||
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):
|
def __init__(self, name: str | None = None):
|
||||||
"""Perform one-time plugin setup."""
|
"""Perform one-time plugin setup."""
|
||||||
|
|
||||||
|
|
|
||||||
2
docs/_templates/autosummary/class.rst
vendored
2
docs/_templates/autosummary/class.rst
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
{{ fullname | escape | underline}}
|
{{ name | escape | underline}}
|
||||||
|
|
||||||
.. currentmodule:: {{ module }}
|
.. currentmodule:: {{ module }}
|
||||||
|
|
||||||
|
|
|
||||||
9
docs/api/index.rst
Normal file
9
docs/api/index.rst
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
API Reference
|
||||||
|
=============
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
:titlesonly:
|
||||||
|
|
||||||
|
plugins
|
||||||
|
database
|
||||||
|
|
@ -7,3 +7,11 @@ Plugins
|
||||||
:toctree: generated/
|
:toctree: generated/
|
||||||
|
|
||||||
BeetsPlugin
|
BeetsPlugin
|
||||||
|
|
||||||
|
.. currentmodule:: beets.metadata_plugins
|
||||||
|
|
||||||
|
.. autosummary::
|
||||||
|
:toctree: generated/
|
||||||
|
|
||||||
|
MetadataSourcePlugin
|
||||||
|
SearchApiMetadataSourcePlugin
|
||||||
|
|
|
||||||
|
|
@ -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
|
"database is locked"). This release synchronizes access to the database to
|
||||||
avoid internal SQLite contention, which should avoid this error.
|
avoid internal SQLite contention, which should avoid this error.
|
||||||
- Plugins can now add parallel stages to the import pipeline. See
|
- 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
|
- 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``
|
query: for example, when running ``beet ls -a artist:foo`` (because ``artist``
|
||||||
is an item-level field).
|
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
|
addition to replacing them) if the special string ``<strip>`` is specified as
|
||||||
the replacement.
|
the replacement.
|
||||||
- New plugin API: plugins can now add fields to the MediaFile tag abstraction
|
- 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
|
- A reasonable error message is now shown when the import log file cannot be
|
||||||
opened.
|
opened.
|
||||||
- The import log file is now flushed and closed properly so that it can be used
|
- 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
|
naming rules: for example, ``%upper{%left{$artist,1}}`` will insert the
|
||||||
capitalized first letter of the track's artist. For more details, see
|
capitalized first letter of the track's artist. For more details, see
|
||||||
:doc:`/reference/pathformat`. If you're interested in adding your own template
|
: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.
|
- Plugins can also now define new path *fields* in addition to functions.
|
||||||
- The new :doc:`/plugins/inline` lets you **use Python expressions to customize
|
- The new :doc:`/plugins/inline` lets you **use Python expressions to customize
|
||||||
path formats** by defining new fields in the config file.
|
path formats** by defining new fields in the config file.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
..
|
.. code_of_conduct:
|
||||||
code_of_conduct:
|
|
||||||
|
|
||||||
.. include:: ../CODE_OF_CONDUCT.rst
|
.. include:: ../CODE_OF_CONDUCT.rst
|
||||||
|
|
|
||||||
18
docs/conf.py
18
docs/conf.py
|
|
@ -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 -------------------------------------------------
|
# -- Options for HTML output -------------------------------------------------
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#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 = "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_title = "beets"
|
||||||
html_logo = "_static/beets_logo_nobg.png"
|
html_logo = "_static/beets_logo_nobg.png"
|
||||||
html_static_path = ["_static"]
|
html_static_path = ["_static"]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
..
|
.. contributing:
|
||||||
contributing:
|
|
||||||
|
|
||||||
.. include:: ../CONTRIBUTING.rst
|
.. include:: ../CONTRIBUTING.rst
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,20 @@ For Developers
|
||||||
This section contains information for developers. Read on if you're interested
|
This section contains information for developers. Read on if you're interested
|
||||||
in hacking beets itself or creating plugins for it.
|
in hacking beets itself or creating plugins for it.
|
||||||
|
|
||||||
See also the documentation for MediaFile_, the library used by beets to read and
|
See also the documentation for the MediaFile_ and Confuse_ libraries. These are
|
||||||
write metadata tags in media files.
|
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/
|
.. _mediafile: https://mediafile.readthedocs.io/en/latest/
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 1
|
:maxdepth: 3
|
||||||
|
:titlesonly:
|
||||||
|
|
||||||
plugins
|
plugins/index
|
||||||
library
|
library
|
||||||
importer
|
importer
|
||||||
cli
|
cli
|
||||||
|
../api/index
|
||||||
.. toctree::
|
|
||||||
:maxdepth: 1
|
|
||||||
:caption: API Reference
|
|
||||||
|
|
||||||
../api/plugins
|
|
||||||
../api/database
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.
|
architecture to orient anyone who wants to dive into the code.
|
||||||
|
|
||||||
The :class:`Library` object is the central repository for data in beets. It
|
The |Library| object is the central repository for data in beets. It represents
|
||||||
represents a database containing songs, which are :class:`Item` instances, and
|
a database containing songs, which are |Item| instances, and groups of items,
|
||||||
groups of items, which are :class:`Album` instances.
|
which are |Album| instances.
|
||||||
|
|
||||||
The Library Class
|
The Library Class
|
||||||
-----------------
|
-----------------
|
||||||
|
|
||||||
The :class:`Library` is typically instantiated as a singleton. A single
|
The |Library| is typically instantiated as a singleton. A single invocation of
|
||||||
invocation of beets usually has only one :class:`Library`. It's powered by
|
beets usually has only one |Library|. It's powered by :class:`dbcore.Database`
|
||||||
:class:`dbcore.Database` under the hood, which handles the SQLite_ abstraction,
|
under the hood, which handles the SQLite_ abstraction, something like a very
|
||||||
something like a very minimal ORM_. The library is also responsible for handling
|
minimal ORM_. The library is also responsible for handling queries to retrieve
|
||||||
queries to retrieve stored objects.
|
stored objects.
|
||||||
|
|
||||||
Overview
|
Overview
|
||||||
~~~~~~~~
|
~~~~~~~~
|
||||||
|
|
@ -40,10 +40,9 @@ which you can get using the :py:meth:`Library.transaction` context manager.
|
||||||
Model Classes
|
Model Classes
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
The two model entities in beets libraries, :class:`Item` and :class:`Album`,
|
The two model entities in beets libraries, |Item| and |Album|, share a base
|
||||||
share a base class, :class:`LibModel`, that provides common functionality. That
|
class, :class:`LibModel`, that provides common functionality. That class itself
|
||||||
class itself specialises :class:`beets.dbcore.Model` which provides an ORM-like
|
specialises :class:`beets.dbcore.Model` which provides an ORM-like abstraction.
|
||||||
abstraction.
|
|
||||||
|
|
||||||
To get or change the metadata of a model (an item or album), either access its
|
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
|
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
|
the database. The dirty dictionary maps field names to booleans indicating
|
||||||
whether the field has been written since the object was last synchronized (via
|
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
|
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 :class:`LibModel` and is inherited by both |Item| and |Album|.
|
||||||
:class:`Album`.
|
|
||||||
|
|
||||||
We provide CRUD-like methods for interacting with the database:
|
We provide CRUD-like methods for interacting with the database:
|
||||||
|
|
||||||
|
|
@ -77,10 +75,10 @@ normal the normal mapping API is supported:
|
||||||
Item
|
Item
|
||||||
~~~~
|
~~~~
|
||||||
|
|
||||||
Each :class:`Item` object represents a song or track. (We use the more generic
|
Each |Item| object represents a song or track. (We use the more generic term
|
||||||
term item because, one day, beets might support non-music media.) An item can
|
item because, one day, beets might support non-music media.) An item can either
|
||||||
either be purely abstract, in which case it's just a bag of metadata fields, or
|
be purely abstract, in which case it's just a bag of metadata fields, or it can
|
||||||
it can have an associated file (indicated by ``item.path``).
|
have an associated file (indicated by ``item.path``).
|
||||||
|
|
||||||
In terms of the underlying SQLite database, items are backed by a single table
|
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
|
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
|
: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.
|
filesystem). This feature turns out to be sort of complicated.
|
||||||
|
|
||||||
For any :class:`Item`, there are two mtimes: the on-disk mtime (maintained by
|
For any |Item|, there are two mtimes: the on-disk mtime (maintained by the OS)
|
||||||
the OS) and the database mtime (maintained by beets). Correspondingly, there is
|
and the database mtime (maintained by beets). Correspondingly, there is on-disk
|
||||||
on-disk metadata (ID3 tags, for example) and DB metadata. The goal with the
|
metadata (ID3 tags, for example) and DB metadata. The goal with the mtime is to
|
||||||
mtime is to ensure that the on-disk and DB mtimes match when the on-disk and DB
|
ensure that the on-disk and DB mtimes match when the on-disk and DB metadata are
|
||||||
metadata are in sync; this lets beets do a quick mtime check and avoid rereading
|
in sync; this lets beets do a quick mtime check and avoid rereading files in
|
||||||
files in some circumstances.
|
some circumstances.
|
||||||
|
|
||||||
Specifically, beets attempts to maintain the following invariant:
|
Specifically, beets attempts to maintain the following invariant:
|
||||||
|
|
||||||
|
|
@ -126,14 +124,14 @@ This leads to the following implementation policy:
|
||||||
Album
|
Album
|
||||||
~~~~~
|
~~~~~
|
||||||
|
|
||||||
An :class:`Album` is a collection of Items in the database. Every item in the
|
An |Album| is a collection of Items in the database. Every item in the database
|
||||||
database has either zero or one associated albums (accessible via
|
has either zero or one associated albums (accessible via ``item.album_id``). An
|
||||||
``item.album_id``). An item that has no associated album is called a singleton.
|
item that has no associated album is called a singleton. Changing fields on an
|
||||||
Changing fields on an album (e.g. ``album.year = 2012``) updates the album
|
album (e.g. ``album.year = 2012``) updates the album itself and also changes the
|
||||||
itself and also changes the same field in all associated items.
|
same field in all associated items.
|
||||||
|
|
||||||
An :class:`Album` object keeps track of album-level metadata, which is (mostly)
|
An |Album| object keeps track of album-level metadata, which is (mostly) a
|
||||||
a subset of the track-level metadata. The album-level metadata fields are listed
|
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
|
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
|
(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
|
same value. Albums use an SQLite table called ``albums``, in which each column
|
||||||
|
|
@ -147,7 +145,7 @@ is an album metadata field.
|
||||||
Transactions
|
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
|
manipulate its contents. To perform more complicated operations atomically, or
|
||||||
to interact directly with the underlying SQLite database, you must use a
|
to interact directly with the underlying SQLite database, you must use a
|
||||||
*transaction* (see this `blog post`_ for motivation). For example
|
*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
|
The ``clause()`` method should return an SQLite ``WHERE`` clause that matches
|
||||||
appropriate albums/items. This allows for efficient batch queries.
|
appropriate albums/items. This allows for efficient batch queries.
|
||||||
Correspondingly, the ``match(item)`` method should take an :class:`Item` object
|
Correspondingly, the ``match(item)`` method should take an |Item| object and
|
||||||
and return a boolean, indicating whether or not a specific item matches the
|
return a boolean, indicating whether or not a specific item matches the
|
||||||
criterion. This alternate implementation allows clients to determine whether
|
criterion. This alternate implementation allows clients to determine whether
|
||||||
items that have already been fetched from the database match the query.
|
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
|
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
|
: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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
107
docs/dev/plugins/autotagger.rst
Normal file
107
docs/dev/plugins/autotagger.rst
Normal 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`
|
||||||
54
docs/dev/plugins/commands.rst
Normal file
54
docs/dev/plugins/commands.rst
Normal 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
199
docs/dev/plugins/events.rst
Normal 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
103
docs/dev/plugins/index.rst
Normal 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
|
||||||
36
docs/dev/plugins/other/config.rst
Normal file
36
docs/dev/plugins/other/config.rst
Normal 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
|
||||||
35
docs/dev/plugins/other/fields.rst
Normal file
35
docs/dev/plugins/other/fields.rst
Normal 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.
|
||||||
88
docs/dev/plugins/other/import.rst
Normal file
88
docs/dev/plugins/other/import.rst
Normal 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}
|
||||||
16
docs/dev/plugins/other/index.rst
Normal file
16
docs/dev/plugins/other/index.rst
Normal 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
|
||||||
38
docs/dev/plugins/other/logging.rst
Normal file
38
docs/dev/plugins/other/logging.rst
Normal 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.)
|
||||||
32
docs/dev/plugins/other/mediafile.rst
Normal file
32
docs/dev/plugins/other/mediafile.rst
Normal 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"
|
||||||
69
docs/dev/plugins/other/prompts.rst
Normal file
69
docs/dev/plugins/other/prompts.rst
Normal 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.
|
||||||
57
docs/dev/plugins/other/templates.rst
Normal file
57
docs/dev/plugins/other/templates.rst
Normal 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.
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
.. _using-the-auto-tagger:
|
||||||
|
|
||||||
Using the Auto-Tagger
|
Using the Auto-Tagger
|
||||||
=====================
|
=====================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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
|
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:
|
.. _using-plugins:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -289,4 +289,4 @@ constructs include:
|
||||||
The :doc:`/plugins/inline` lets you define template fields in your beets
|
The :doc:`/plugins/inline` lets you define template fields in your beets
|
||||||
configuration file using Python snippets. And for more advanced processing, you
|
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
|
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
40
poetry.lock
generated
|
|
@ -696,28 +696,29 @@ files = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "docstrfmt"
|
name = "docstrfmt"
|
||||||
version = "1.10.0"
|
version = "1.11.1"
|
||||||
description = "docstrfmt: A formatter for Sphinx flavored reStructuredText."
|
description = "docstrfmt: A formatter for Sphinx flavored reStructuredText."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "<4,>=3.8"
|
python-versions = ">=3.9"
|
||||||
files = [
|
files = [
|
||||||
{file = "docstrfmt-1.10.0-py3-none-any.whl", hash = "sha256:a34ef6f3d8ab3233a7d0b3d1c2f3c66f8acbb3917df5ed2f3e34c1629ac29cef"},
|
{file = "docstrfmt-1.11.1-py3-none-any.whl", hash = "sha256:6782d8663321c3a7c40be08a36fbcb1ea9e46d1efba85411ba807d97f384871a"},
|
||||||
{file = "docstrfmt-1.10.0.tar.gz", hash = "sha256:9da96e71552937f4b49ae2d6ab1c118ffa8ad6968082e6b8fd978b01d1bc0066"},
|
{file = "docstrfmt-1.11.1.tar.gz", hash = "sha256:d41e19d6c5d524cc7f8ff6cbfecb8762d77e696b9fe4f5057269051fb966fc80"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
black = "==24.*"
|
black = ">=24"
|
||||||
click = "==8.*"
|
click = ">=8"
|
||||||
docutils = "==0.20.*"
|
docutils = ">=0.20"
|
||||||
libcst = "==1.*"
|
libcst = ">=1"
|
||||||
platformdirs = "==4.*"
|
platformdirs = ">=4"
|
||||||
sphinx = ">=7,<9"
|
roman = "*"
|
||||||
tabulate = "==0.9.*"
|
sphinx = ">=7"
|
||||||
toml = "==0.10.*"
|
tabulate = ">=0.9"
|
||||||
|
toml = {version = ">=0.10", markers = "python_version < \"3.11\""}
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
ci = ["coveralls"]
|
ci = ["coveralls"]
|
||||||
d = ["aiohttp (==3.*)"]
|
d = ["aiohttp (>=3)"]
|
||||||
dev = ["docstrfmt[lint]", "docstrfmt[test]", "packaging"]
|
dev = ["docstrfmt[lint]", "docstrfmt[test]", "packaging"]
|
||||||
lint = ["pre-commit", "ruff (>=0.0.292)"]
|
lint = ["pre-commit", "ruff (>=0.0.292)"]
|
||||||
test = ["pytest", "pytest-aiohttp"]
|
test = ["pytest", "pytest-aiohttp"]
|
||||||
|
|
@ -2921,6 +2922,17 @@ urllib3 = ">=1.25.10,<3.0"
|
||||||
[package.extras]
|
[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"]
|
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]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
|
|
@ -3617,4 +3629,4 @@ web = ["flask", "flask-cors"]
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = ">=3.9,<4"
|
python-versions = ">=3.9,<4"
|
||||||
content-hash = "daa6c3c2b5bee3180f74f4186bb29ee1ad825870b5b9f6c2b743fcaa61b34c8c"
|
content-hash = "faea27878ce1ca3f1335fd83e027b289351c51c73550bda72bf501a9c82166f7"
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ requests_oauthlib = "*"
|
||||||
responses = ">=0.3.0"
|
responses = ">=0.3.0"
|
||||||
|
|
||||||
[tool.poetry.group.lint.dependencies]
|
[tool.poetry.group.lint.dependencies]
|
||||||
docstrfmt = ">=1.10.0"
|
docstrfmt = ">=1.11.1"
|
||||||
ruff = ">=0.6.4"
|
ruff = ">=0.6.4"
|
||||||
sphinx-lint = ">=1.0.0"
|
sphinx-lint = ">=1.0.0"
|
||||||
|
|
||||||
|
|
@ -196,7 +196,8 @@ cmd = "mypy"
|
||||||
|
|
||||||
[tool.poe.tasks.docs]
|
[tool.poe.tasks.docs]
|
||||||
help = "Build documentation"
|
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]
|
[tool.poe.tasks.format]
|
||||||
help = "Format the codebase"
|
help = "Format the codebase"
|
||||||
|
|
@ -212,7 +213,7 @@ cmd = "ruff check"
|
||||||
|
|
||||||
[tool.poe.tasks.lint-docs]
|
[tool.poe.tasks.lint-docs]
|
||||||
help = "Lint the documentation"
|
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]
|
[tool.poe.tasks.update-dependencies]
|
||||||
help = "Update dependencies to their latest versions."
|
help = "Update dependencies to their latest versions."
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue