Merge branch 'master' into feature/web-handle-nexttrack

This commit is contained in:
Šarūnas Nejus 2025-10-20 00:31:46 +01:00 committed by GitHub
commit 99987b3f27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 1777 additions and 890 deletions

View file

@ -10,7 +10,7 @@ jobs:
check_changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Get all updated Python files
id: changed-python-files

View file

@ -25,12 +25,12 @@ jobs:
env:
IS_MAIN_PYTHON: ${{ matrix.python-version == '3.9' && matrix.platform == 'ubuntu-latest' }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- name: Setup Python with poetry caching
# poetry cache requires poetry to already be installed, weirdly
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: poetry
@ -90,10 +90,10 @@ jobs:
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Get the coverage report
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: coverage-report

View file

@ -7,10 +7,10 @@ jobs:
test_integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: 3.9
cache: poetry

View file

@ -24,7 +24,7 @@ jobs:
changed_doc_files: ${{ steps.changed-doc-files.outputs.all_changed_files }}
changed_python_files: ${{ steps.changed-python-files.outputs.all_changed_files }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Get changed docs files
id: changed-doc-files
uses: tj-actions/changed-files@v46
@ -56,10 +56,10 @@ jobs:
name: Check formatting
needs: changed-files
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
@ -77,10 +77,10 @@ jobs:
name: Check linting
needs: changed-files
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
@ -97,10 +97,10 @@ jobs:
name: Check types with mypy
needs: changed-files
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
@ -120,10 +120,10 @@ jobs:
name: Check docs
needs: changed-files
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry

View file

@ -17,10 +17,10 @@ jobs:
name: Bump version, commit and create tag
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
@ -45,13 +45,13 @@ jobs:
outputs:
changelog: ${{ steps.generate_changelog.outputs.changelog }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
ref: ${{ env.NEW_TAG }}
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
@ -92,7 +92,7 @@ jobs:
id-token: write
steps:
- name: Download all the dists
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: python-package-distributions
path: dist/
@ -107,7 +107,7 @@ jobs:
CHANGELOG: ${{ needs.build.outputs.changelog }}
steps:
- name: Download all the dists
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: python-package-distributions
path: dist/

4
.gitignore vendored
View file

@ -95,5 +95,5 @@ ENV/
# pyright
pyrightconfig.json
# Versioning
beets/_version.py
# Pyrefly
pyrefly.toml

View file

@ -17,10 +17,9 @@ from sys import stderr
import confuse
# Version management using poetry-dynamic-versioning
from ._version import __version__, __version_tuple__
from .util import deprecate_imports
__version__ = "2.5.1"
__author__ = "Adrian Sampson <adrian@radbox.org>"
@ -55,6 +54,3 @@ class IncludeLazyConfig(confuse.LazyConfig):
config = IncludeLazyConfig("beets", __name__)
__all__ = ["__version__", "__version_tuple__", "config"]

View file

@ -1,7 +0,0 @@
# This file is auto-generated during the build process.
# Do not edit this file directly.
# Placeholders are replaced during substitution.
# Run `git update-index --assume-unchanged beets/_version.py`
# to ignore local changes to this file.
__version__ = "0.0.0"
__version_tuple__ = (0, 0, 0)

View file

@ -345,6 +345,12 @@ class Distance:
dist = string_dist(str1, str2)
self.add(key, dist)
def add_data_source(self, before: str | None, after: str | None) -> None:
if before != after and (
before or len(metadata_plugins.find_metadata_source_plugins()) > 1
):
self.add("data_source", metadata_plugins.get_penalty(after))
@cache
def get_track_length_grace() -> float:
@ -408,8 +414,7 @@ def track_distance(
if track_info.medium and item.disc:
dist.add_expr("medium", item.disc != track_info.medium)
# Plugins.
dist.update(metadata_plugins.track_distance(item, track_info))
dist.add_data_source(item.get("data_source"), track_info.data_source)
return dist
@ -525,7 +530,6 @@ def distance(
for _ in range(len(items) - len(mapping)):
dist.add("unmatched_tracks", 1.0)
# Plugins.
dist.update(metadata_plugins.album_distance(items, album_info, mapping))
dist.add_data_source(likelies["data_source"], album_info.data_source)
return dist

View file

@ -166,7 +166,7 @@ match:
missing_tracks: medium
unmatched_tracks: medium
distance_weights:
source: 2.0
data_source: 2.0
artist: 3.0
album: 3.0
media: 1.0

View file

@ -940,10 +940,10 @@ class Transaction:
def __exit__(
self,
exc_type: type[Exception],
exc_value: Exception,
traceback: TracebackType,
):
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> bool | None:
"""Complete a transaction. This must be the most recently
entered but not yet exited transaction. If it is the last active
transaction, the database updates are committed.
@ -965,6 +965,8 @@ class Transaction:
):
raise DBCustomFunctionError()
return None
def query(
self, statement: str, subvals: Sequence[SQLiteType] = ()
) -> list[sqlite3.Row]:

View file

@ -9,10 +9,11 @@ from __future__ import annotations
import abc
import re
import warnings
from functools import cache, cached_property
from typing import TYPE_CHECKING, Generic, Literal, Sequence, TypedDict, TypeVar
import unidecode
from confuse import NotFoundError
from typing_extensions import NotRequired
from beets.util import cached_classproperty
@ -23,36 +24,14 @@ from .plugins import BeetsPlugin, find_plugins, notify_info_yielded, send
if TYPE_CHECKING:
from collections.abc import Iterable
from confuse import ConfigView
from .autotag import Distance
from .autotag.hooks import AlbumInfo, Item, TrackInfo
@cache
def find_metadata_source_plugins() -> list[MetadataSourcePlugin]:
"""Returns a list of MetadataSourcePlugin subclass instances
Resolved from all currently loaded beets plugins.
"""
all_plugins = find_plugins()
metadata_plugins: list[MetadataSourcePlugin | BeetsPlugin] = []
for plugin in all_plugins:
if isinstance(plugin, MetadataSourcePlugin):
metadata_plugins.append(plugin)
elif hasattr(plugin, "data_source"):
# TODO: Remove this in the future major release, v3.0.0
warnings.warn(
f"{plugin.__class__.__name__} is used as a legacy metadata source. "
"It should extend MetadataSourcePlugin instead of BeetsPlugin. "
"Support for this will be removed in the v3.0.0 release!",
DeprecationWarning,
stacklevel=2,
)
metadata_plugins.append(plugin)
# typeignore: BeetsPlugin is not a MetadataSourcePlugin (legacy support)
return metadata_plugins # type: ignore[return-value]
"""Return a list of all loaded metadata source plugins."""
# TODO: Make this an isinstance(MetadataSourcePlugin, ...) check in v3.0.0
return [p for p in find_plugins() if hasattr(p, "data_source")] # type: ignore[misc]
@notify_info_yielded("albuminfo_received")
@ -95,46 +74,17 @@ def track_for_id(_id: str) -> TrackInfo | None:
return None
def track_distance(item: Item, info: TrackInfo) -> Distance:
"""Returns the track distance for an item and trackinfo.
Returns a Distance object is populated by all metadata source plugins
that implement the :py:meth:`MetadataSourcePlugin.track_distance` method.
"""
from beets.autotag.distance import Distance
dist = Distance()
for plugin in find_metadata_source_plugins():
dist.update(plugin.track_distance(item, info))
return dist
def album_distance(
items: Sequence[Item],
album_info: AlbumInfo,
mapping: dict[Item, TrackInfo],
) -> Distance:
"""Returns the album distance calculated by plugins."""
from beets.autotag.distance import Distance
dist = Distance()
for plugin in find_metadata_source_plugins():
dist.update(plugin.album_distance(items, album_info, mapping))
return dist
def _get_distance(
config: ConfigView, data_source: str, info: AlbumInfo | TrackInfo
) -> Distance:
"""Returns the ``data_source`` weight and the maximum source weight
for albums or individual tracks.
"""
from beets.autotag.distance import Distance
dist = Distance()
if info.data_source == data_source:
dist.add("source", config["source_weight"].as_number())
return dist
@cache
def get_penalty(data_source: str | None) -> float:
"""Get the penalty value for the given data source."""
return next(
(
p.data_source_mismatch_penalty
for p in find_metadata_source_plugins()
if p.data_source == data_source
),
MetadataSourcePlugin.DEFAULT_DATA_SOURCE_MISMATCH_PENALTY,
)
class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta):
@ -145,12 +95,29 @@ class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta):
and tracks, and to retrieve album and track information by ID.
"""
DEFAULT_DATA_SOURCE_MISMATCH_PENALTY = 0.5
@cached_classproperty
def data_source(cls) -> str:
"""The data source name for this plugin.
This is inferred from the plugin name.
"""
return cls.__name__.replace("Plugin", "") # type: ignore[attr-defined]
@cached_property
def data_source_mismatch_penalty(self) -> float:
try:
return self.config["source_weight"].as_number()
except NotFoundError:
return self.config["data_source_mismatch_penalty"].as_number()
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.config.add(
{
"search_limit": 5,
"source_weight": 0.5,
"data_source_mismatch_penalty": self.DEFAULT_DATA_SOURCE_MISMATCH_PENALTY, # noqa: E501
}
)
@ -224,35 +191,6 @@ class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta):
return (self.track_for_id(id) for id in ids)
def album_distance(
self,
items: Sequence[Item],
album_info: AlbumInfo,
mapping: dict[Item, TrackInfo],
) -> Distance:
"""Calculate the distance for an album based on its items and album info."""
return _get_distance(
data_source=self.data_source, info=album_info, config=self.config
)
def track_distance(
self,
item: Item,
info: TrackInfo,
) -> Distance:
"""Calculate the distance for a track based on its item and track info."""
return _get_distance(
data_source=self.data_source, info=info, config=self.config
)
@cached_classproperty
def data_source(cls) -> str:
"""The data source name for this plugin.
This is inferred from the plugin name.
"""
return cls.__name__.replace("Plugin", "") # type: ignore[attr-defined]
def _extract_id(self, url: str) -> str | None:
"""Extract an ID from a URL for this metadata source plugin.

View file

@ -20,8 +20,9 @@ import abc
import inspect
import re
import sys
import warnings
from collections import defaultdict
from functools import wraps
from functools import cached_property, wraps
from importlib import import_module
from pathlib import Path
from types import GenericAlias
@ -160,19 +161,57 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
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
"""Enable legacy metadatasource plugins to work with the new interface.
When a plugin subclass of BeetsPlugin defines a `data_source` attribute
but does not inherit from MetadataSourcePlugin, this hook:
1. Skips abstract classes.
2. Warns that the class should extend MetadataSourcePlugin (deprecation).
3. Copies any nonabstract methods from MetadataSourcePlugin onto the
subclass to provide the full plugin API.
This compatibility layer will be removed in the v3.0.0 release.
"""
# TODO: Remove in 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 issubclass(cls, MetadataSourcePlugin) or not hasattr(
cls, "data_source"
):
if name not in abstractmethods and not hasattr(cls, name):
setattr(cls, name, method)
return
warnings.warn(
f"{cls.__name__} is used as a legacy metadata source. "
"It should extend MetadataSourcePlugin instead of BeetsPlugin. "
"Support for this will be removed in the v3.0.0 release!",
DeprecationWarning,
stacklevel=3,
)
method: property | cached_property[Any] | Callable[..., Any]
for name, method in inspect.getmembers(
MetadataSourcePlugin,
predicate=lambda f: ( # type: ignore[arg-type]
(
isinstance(f, (property, cached_property))
and not hasattr(
BeetsPlugin,
getattr(f, "attrname", None) or f.fget.__name__, # type: ignore[union-attr]
)
)
or (
inspect.isfunction(f)
and f.__name__
and not getattr(f, "__isabstractmethod__", False)
and not hasattr(BeetsPlugin, f.__name__)
)
),
):
setattr(cls, name, method)
def __init__(self, name: str | None = None):
"""Perform one-time plugin setup."""
@ -197,6 +236,37 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
if not any(isinstance(f, PluginLogFilter) for f in self._log.filters):
self._log.addFilter(PluginLogFilter(self))
# In order to verify the config we need to make sure the plugin is fully
# configured (plugins usually add the default configuration *after*
# calling super().__init__()).
self.register_listener("pluginload", self._verify_config)
def _verify_config(self, *_, **__) -> None:
"""Verify plugin configuration.
If deprecated 'source_weight' option is explicitly set by the user, they
will see a warning in the logs. Otherwise, this must be configured by
a third party plugin, thus we raise a deprecation warning which won't be
shown to user but will be visible to plugin developers.
"""
# TODO: Remove in v3.0.0
if (
not hasattr(self, "data_source")
or "source_weight" not in self.config
):
return
message = (
"'source_weight' configuration option is deprecated and will be"
" removed in v3.0.0. Use 'data_source_mismatch_penalty' instead"
)
for source in self.config.root().sources:
if "source_weight" in (source.get(self.name) or {}):
if source.filename: # user config
self._log.warning(message)
else: # 3rd-party plugin config
warnings.warn(message, DeprecationWarning, stacklevel=0)
def commands(self) -> Sequence[Subcommand]:
"""Should return a list of beets.ui.Subcommand objects for
commands that should be added to beets' CLI.
@ -363,6 +433,12 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
Attempts to import the plugin module, locate the appropriate plugin class
within it, and return an instance. Handles import failures gracefully and
logs warnings for missing plugins or loading errors.
Note we load the *last* plugin class found in the plugin namespace. This
allows plugins to define helper classes that inherit from BeetsPlugin
without those being loaded as the main plugin class.
Returns None if the plugin could not be loaded for any reason.
"""
try:
try:
@ -370,7 +446,7 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
except Exception as exc:
raise PluginImportError(name) from exc
for obj in namespace.__dict__.values():
for obj in reversed(namespace.__dict__.values()):
if (
inspect.isclass(obj)
and not isinstance(
@ -573,13 +649,17 @@ def send(event: EventType, **arguments: Any) -> list[Any]:
]
def feat_tokens(for_artist: bool = True) -> str:
def feat_tokens(
for_artist: bool = True, custom_words: list[str] | None = None
) -> str:
"""Return a regular expression that matches phrases like "featuring"
that separate a main artist or a song title from secondary artists.
The `for_artist` option determines whether the regex should be
suitable for matching artist fields (the default) or title fields.
"""
feat_words = ["ft", "featuring", "feat", "feat.", "ft."]
if isinstance(custom_words, list):
feat_words += custom_words
if for_artist:
feat_words += ["with", "vs", "and", "con", "&"]
return (

View file

@ -58,7 +58,6 @@ from beets.ui.commands import TerminalImportSession
from beets.util import (
MoveOperation,
bytestring_path,
cached_classproperty,
clean_module_tempdir,
syspath,
)
@ -495,7 +494,6 @@ class PluginMixin(ConfigMixin):
# FIXME this should eventually be handled by a plugin manager
plugins = (self.plugin,) if hasattr(self, "plugin") else plugins
self.config["plugins"] = plugins
cached_classproperty.cache.clear()
beets.plugins.load_plugins()
def unload_plugins(self) -> None:

View file

@ -1078,7 +1078,9 @@ def _field_diff(field, old, old_fmt, new, new_fmt):
return f"{oldstr} -> {newstr}"
def show_model_changes(new, old=None, fields=None, always=False):
def show_model_changes(
new, old=None, fields=None, always=False, print_obj: bool = True
):
"""Given a Model object, print a list of changes from its pristine
version stored in the database. Return a boolean indicating whether
any changes were found.
@ -1117,7 +1119,7 @@ def show_model_changes(new, old=None, fields=None, always=False):
)
# Print changes.
if changes or always:
if print_obj and (changes or always):
print_(format(old))
if changes:
print_("\n".join(changes))

View file

@ -47,6 +47,7 @@ from typing import (
NamedTuple,
TypeVar,
Union,
cast,
)
from unidecode import unidecode
@ -836,9 +837,10 @@ def get_most_common_tags(
"country",
"media",
"albumdisambig",
"data_source",
]
for field in fields:
values = [item[field] for item in items if item]
values = [item.get(field) for item in items if item]
likelies[field], freq = plurality(values)
consensus[field] = freq == len(values)
@ -1051,7 +1053,7 @@ def par_map(transform: Callable[[T], Any], items: Sequence[T]) -> None:
pool.join()
class cached_classproperty:
class cached_classproperty(Generic[T]):
"""Descriptor implementing cached class properties.
Provides class-level dynamic property behavior where the getter function is
@ -1059,9 +1061,9 @@ class cached_classproperty:
instance properties, this operates on the class rather than instances.
"""
cache: ClassVar[dict[tuple[Any, str], Any]] = {}
cache: ClassVar[dict[tuple[type[object], str], object]] = {}
name: str
name: str = ""
# Ideally, we would like to use `Callable[[type[T]], Any]` here,
# however, `mypy` is unable to see this as a **class** property, and thinks
@ -1077,21 +1079,21 @@ class cached_classproperty:
# "Callable[[Album], ...]"; expected "Callable[[type[Album]], ...]"
#
# Therefore, we just use `Any` here, which is not ideal, but works.
def __init__(self, getter: Callable[[Any], Any]) -> None:
def __init__(self, getter: Callable[..., T]) -> None:
"""Initialize the descriptor with the property getter function."""
self.getter = getter
self.getter: Callable[..., T] = getter
def __set_name__(self, owner: Any, name: str) -> None:
def __set_name__(self, owner: object, name: str) -> None:
"""Capture the attribute name this descriptor is assigned to."""
self.name = name
def __get__(self, instance: Any, owner: type[Any]) -> Any:
def __get__(self, instance: object, owner: type[object]) -> T:
"""Compute and cache if needed, and return the property value."""
key = owner, self.name
key: tuple[type[object], str] = owner, self.name
if key not in self.cache:
self.cache[key] = self.getter(owner)
return self.cache[key]
return cast(T, self.cache[key])
class LazySharedInstance(Generic[T]):

View file

@ -328,7 +328,6 @@ class BeatportPlugin(MetadataSourcePlugin):
"apikey": "57713c3906af6f5def151b33601389176b37b429",
"apisecret": "b3fe08c93c80aefd749fe871a16cd2bb32e2b954",
"tokenfile": "beatport_token.json",
"source_weight": 0.5,
}
)
self.config["apikey"].redact = True

View file

@ -129,13 +129,12 @@ class DiscogsPlugin(MetadataSourcePlugin):
"apikey": API_KEY,
"apisecret": API_SECRET,
"tokenfile": "discogs_token.json",
"source_weight": 0.5,
"user_token": "",
"separator": ", ",
"index_tracks": False,
"featured_string": "Feat.",
"append_style_genre": False,
"strip_disambiguation": True,
"featured_string": "Feat.",
"anv": {
"artist_credit": True,
"artist": False,

View file

@ -27,7 +27,9 @@ if TYPE_CHECKING:
def split_on_feat(
artist: str, for_artist: bool = True
artist: str,
for_artist: bool = True,
custom_words: list[str] | None = None,
) -> tuple[str, str | None]:
"""Given an artist string, split the "main" artist from any artist
on the right-hand side of a string like "feat". Return the main
@ -35,7 +37,9 @@ def split_on_feat(
may be a string or None if none is present.
"""
# split on the first "feat".
regex = re.compile(plugins.feat_tokens(for_artist), re.IGNORECASE)
regex = re.compile(
plugins.feat_tokens(for_artist, custom_words), re.IGNORECASE
)
parts = tuple(s.strip() for s in regex.split(artist, 1))
if len(parts) == 1:
return parts[0], None
@ -44,18 +48,22 @@ def split_on_feat(
return parts
def contains_feat(title: str) -> bool:
def contains_feat(title: str, custom_words: list[str] | None = None) -> bool:
"""Determine whether the title contains a "featured" marker."""
return bool(
re.search(
plugins.feat_tokens(for_artist=False),
plugins.feat_tokens(for_artist=False, custom_words=custom_words),
title,
flags=re.IGNORECASE,
)
)
def find_feat_part(artist: str, albumartist: str | None) -> str | None:
def find_feat_part(
artist: str,
albumartist: str | None,
custom_words: list[str] | None = None,
) -> str | None:
"""Attempt to find featured artists in the item's artist fields and
return the results. Returns None if no featured artist found.
"""
@ -69,20 +77,24 @@ def find_feat_part(artist: str, albumartist: str | None) -> str | None:
# featured artist.
if albumartist_split[1] != "":
# Extract the featured artist from the right-hand side.
_, feat_part = split_on_feat(albumartist_split[1])
_, feat_part = split_on_feat(
albumartist_split[1], custom_words=custom_words
)
return feat_part
# Otherwise, if there's nothing on the right-hand side,
# look for a featuring artist on the left-hand side.
else:
lhs, _ = split_on_feat(albumartist_split[0])
lhs, _ = split_on_feat(
albumartist_split[0], custom_words=custom_words
)
if lhs:
return lhs
# Fall back to conservative handling of the track artist without relying
# on albumartist, which covers compilations using a 'Various Artists'
# albumartist and album tracks by a guest artist featuring a third artist.
_, feat_part = split_on_feat(artist, False)
_, feat_part = split_on_feat(artist, False, custom_words)
return feat_part
@ -96,6 +108,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
"drop": False,
"format": "feat. {}",
"keep_in_artist": False,
"custom_words": [],
}
)
@ -120,10 +133,13 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
self.config.set_args(opts)
drop_feat = self.config["drop"].get(bool)
keep_in_artist_field = self.config["keep_in_artist"].get(bool)
custom_words = self.config["custom_words"].get(list)
write = ui.should_write()
for item in lib.items(args):
if self.ft_in_title(item, drop_feat, keep_in_artist_field):
if self.ft_in_title(
item, drop_feat, keep_in_artist_field, custom_words
):
item.store()
if write:
item.try_write()
@ -135,9 +151,12 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
"""Import hook for moving featuring artist automatically."""
drop_feat = self.config["drop"].get(bool)
keep_in_artist_field = self.config["keep_in_artist"].get(bool)
custom_words = self.config["custom_words"].get(list)
for item in task.imported_items():
if self.ft_in_title(item, drop_feat, keep_in_artist_field):
if self.ft_in_title(
item, drop_feat, keep_in_artist_field, custom_words
):
item.store()
def update_metadata(
@ -146,6 +165,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
feat_part: str,
drop_feat: bool,
keep_in_artist_field: bool,
custom_words: list[str],
) -> None:
"""Choose how to add new artists to the title and set the new
metadata. Also, print out messages about any changes that are made.
@ -158,17 +178,21 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
"artist: {.artist} (Not changing due to keep_in_artist)", item
)
else:
track_artist, _ = split_on_feat(item.artist)
track_artist, _ = split_on_feat(
item.artist, custom_words=custom_words
)
self._log.info("artist: {0.artist} -> {1}", item, track_artist)
item.artist = track_artist
if item.artist_sort:
# Just strip the featured artist from the sort name.
item.artist_sort, _ = split_on_feat(item.artist_sort)
item.artist_sort, _ = split_on_feat(
item.artist_sort, custom_words=custom_words
)
# Only update the title if it does not already contain a featured
# artist and if we do not drop featuring information.
if not drop_feat and not contains_feat(item.title):
if not drop_feat and not contains_feat(item.title, custom_words):
feat_format = self.config["format"].as_str()
new_format = feat_format.format(feat_part)
new_title = f"{item.title} {new_format}"
@ -180,6 +204,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
item: Item,
drop_feat: bool,
keep_in_artist_field: bool,
custom_words: list[str],
) -> bool:
"""Look for featured artists in the item's artist fields and move
them to the title.
@ -196,19 +221,21 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
if albumartist and artist == albumartist:
return False
_, featured = split_on_feat(artist)
_, featured = split_on_feat(artist, custom_words=custom_words)
if not featured:
return False
self._log.info("{.filepath}", item)
# Attempt to find the featured artist.
feat_part = find_feat_part(artist, albumartist)
feat_part = find_feat_part(artist, albumartist, custom_words)
if not feat_part:
self._log.info("no featuring artists found")
return False
# If we have a featuring artist, move it to the title.
self.update_metadata(item, feat_part, drop_feat, keep_in_artist_field)
self.update_metadata(
item, feat_part, drop_feat, keep_in_artist_field, custom_words
)
return True

View file

@ -22,10 +22,13 @@ The scraper script used is available here:
https://gist.github.com/1241307
"""
from __future__ import annotations
import os
import traceback
from functools import singledispatchmethod
from pathlib import Path
from typing import Union
from typing import TYPE_CHECKING, Union
import pylast
import yaml
@ -34,6 +37,9 @@ from beets import config, library, plugins, ui
from beets.library import Album, Item
from beets.util import plurality, unique_list
if TYPE_CHECKING:
from beets.library import LibModel
LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY)
PYLAST_EXCEPTIONS = (
@ -101,6 +107,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
"prefer_specific": False,
"title_case": True,
"extended_debug": False,
"pretend": False,
}
)
self.setup()
@ -321,7 +328,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
return self.config["separator"].as_str().join(formatted)
def _get_existing_genres(self, obj: Union[Album, Item]) -> list[str]:
def _get_existing_genres(self, obj: LibModel) -> list[str]:
"""Return a list of genres for this Item or Album. Empty string genres
are removed."""
separator = self.config["separator"].get()
@ -342,9 +349,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
combined = old + new
return self._resolve_genres(combined)
def _get_genre(
self, obj: Union[Album, Item]
) -> tuple[Union[str, None], ...]:
def _get_genre(self, obj: LibModel) -> tuple[Union[str, None], ...]:
"""Get the final genre string for an Album or Item object.
`self.sources` specifies allowed genre sources. Starting with the first
@ -459,6 +464,39 @@ class LastGenrePlugin(plugins.BeetsPlugin):
# Beets plugin hooks and CLI.
def _fetch_and_log_genre(self, obj: LibModel) -> None:
"""Fetch genre and log it."""
self._log.info(str(obj))
obj.genre, label = self._get_genre(obj)
self._log.debug("Resolved ({}): {}", label, obj.genre)
ui.show_model_changes(obj, fields=["genre"], print_obj=False)
@singledispatchmethod
def _process(self, obj: LibModel, write: bool) -> None:
"""Process an object, dispatching to the appropriate method."""
raise NotImplementedError
@_process.register
def _process_track(self, obj: Item, write: bool) -> None:
"""Process a single track/item."""
self._fetch_and_log_genre(obj)
if not self.config["pretend"]:
obj.try_sync(write=write, move=False)
@_process.register
def _process_album(self, obj: Album, write: bool) -> None:
"""Process an entire album."""
self._fetch_and_log_genre(obj)
if "track" in self.sources:
for item in obj.items():
self._process(item, write)
if not self.config["pretend"]:
obj.try_sync(
write=write, move=False, inherit="track" not in self.sources
)
def commands(self):
lastgenre_cmd = ui.Subcommand("lastgenre", help="fetch genres")
lastgenre_cmd.parser.add_option(
@ -526,101 +564,17 @@ class LastGenrePlugin(plugins.BeetsPlugin):
lastgenre_cmd.parser.set_defaults(album=True)
def lastgenre_func(lib, opts, args):
write = ui.should_write()
pretend = getattr(opts, "pretend", False)
self.config.set_args(opts)
if opts.album:
# Fetch genres for whole albums
for album in lib.albums(args):
album_genre, src = self._get_genre(album)
prefix = "Pretend: " if pretend else ""
self._log.info(
'{}genre for album "{.album}" ({}): {}',
prefix,
album,
src,
album_genre,
)
if not pretend:
album.genre = album_genre
if "track" in self.sources:
album.store(inherit=False)
else:
album.store()
for item in album.items():
# If we're using track-level sources, also look up each
# track on the album.
if "track" in self.sources:
item_genre, src = self._get_genre(item)
self._log.info(
'{}genre for track "{.title}" ({}): {}',
prefix,
item,
src,
item_genre,
)
if not pretend:
item.genre = item_genre
item.store()
if write and not pretend:
item.try_write()
else:
# Just query singletons, i.e. items that are not part of
# an album
for item in lib.items(args):
item_genre, src = self._get_genre(item)
prefix = "Pretend: " if pretend else ""
self._log.info(
'{}genre for track "{0.title}" ({1}): {}',
prefix,
item,
src,
item_genre,
)
if not pretend:
item.genre = item_genre
item.store()
if write and not pretend:
item.try_write()
method = lib.albums if opts.album else lib.items
for obj in method(args):
self._process(obj, write=ui.should_write())
lastgenre_cmd.func = lastgenre_func
return [lastgenre_cmd]
def imported(self, session, task):
"""Event hook called when an import task finishes."""
if task.is_album:
album = task.album
album.genre, src = self._get_genre(album)
self._log.debug(
'genre for album "{0.album}" ({1}): {0.genre}', album, src
)
# If we're using track-level sources, store the album genre only,
# then also look up individual track genres.
if "track" in self.sources:
album.store(inherit=False)
for item in album.items():
item.genre, src = self._get_genre(item)
self._log.debug(
'genre for track "{0.title}" ({1}): {0.genre}',
item,
src,
)
item.store()
# Store the album genre and inherit to tracks.
else:
album.store()
else:
item = task.item
item.genre, src = self._get_genre(item)
self._log.debug(
'genre for track "{0.title}" ({1}): {0.genre}', item, src
)
item.store()
self._process(task.album if task.is_album else task.item, write=False)
def _tags_for(self, obj, min_weight=None):
"""Core genre identification routine.

View file

@ -28,6 +28,11 @@ from beets.util import get_temp_filename
# If this is missing, they're placed at the end.
ARGS_MARKER = "$args"
# Indicate where the playlist file (with absolute path) should be inserted into
# the command string. If this is missing, its placed at the end, but before
# arguments.
PLS_MARKER = "$playlist"
def play(
command_str,
@ -132,8 +137,23 @@ class PlayPlugin(BeetsPlugin):
return
open_args = self._playlist_or_paths(paths)
open_args_str = [
p.decode("utf-8") for p in self._playlist_or_paths(paths)
]
command_str = self._command_str(opts.args)
if PLS_MARKER in command_str:
if not config["play"]["raw"]:
command_str = command_str.replace(
PLS_MARKER, "".join(open_args_str)
)
self._log.debug(
"command altered by PLS_MARKER to: {}", command_str
)
open_args = []
else:
command_str = command_str.replace(PLS_MARKER, " ")
# Check if the selection exceeds configured threshold. If True,
# cancel, otherwise proceed with play command.
if opts.yes or not self._exceeds_threshold(
@ -162,6 +182,7 @@ class PlayPlugin(BeetsPlugin):
return paths
else:
return [self._create_tmp_playlist(paths)]
return [shlex.quote(self._create_tmp_playlist(paths))]
def _exceeds_threshold(
self, selection, command_str, open_args, item_type="track"

View file

@ -41,6 +41,7 @@ class ZeroPlugin(BeetsPlugin):
"fields": [],
"keep_fields": [],
"update_database": False,
"omit_single_disc": False,
}
)
@ -123,9 +124,14 @@ class ZeroPlugin(BeetsPlugin):
"""
fields_set = False
if "disc" in tags and self.config["omit_single_disc"].get(bool):
if item.disctotal == 1:
fields_set = True
self._log.debug("disc: {.disc} -> None", item)
tags["disc"] = None
if not self.fields_to_progs:
self._log.warning("no fields, nothing to do")
return False
self._log.warning("no fields list to remove")
for field, progs in self.fields_to_progs.items():
if field in tags:

View file

@ -9,16 +9,66 @@ Unreleased
New features:
- :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle.
- :doc: `/plugins/play`: Added `$playlist` marker to precisely edit the playlist
filepath into the command calling the player program.
Bug fixes:
For packagers:
Other changes:
2.5.1 (October 14, 2025)
------------------------
New features:
- :doc:`plugins/zero`: Add new configuration option, ``omit_single_disc``, to
allow zeroing the disc number on write for single-disc albums. Defaults to
False.
Bug fixes:
- |BeetsPlugin|: load the last plugin class defined in the plugin namespace.
:bug:`6093`
For packagers:
- Fixed issue with legacy metadata plugins not copying properties from the base
class.
- Reverted the following: When installing ``beets`` via git or locally the
version string now reflects the current git branch and commit hash.
:bug:`6089`
Other changes:
- Removed outdated mailing list contact information from the documentation
:bug:`5462`.
- :doc:`guides/main`: Modernized the *Getting Started* guide with tabbed
sections and dropdown menus. Installation instructions have been streamlined,
and a new subpage now provides additional setup details.
- Documentation: introduced a new role ``conf`` for documenting configuration
options. This role provides consistent formatting and creates references
automatically. Applied it to :doc:`plugins/deezer`, :doc:`plugins/discogs`,
:doc:`plugins/musicbrainz` and :doc:`plugins/spotify` plugins documentation.
2.5.0 (October 11, 2025)
------------------------
New features:
- :doc:`plugins/lastgenre`: Add a ``--pretend`` option to preview genre changes
without storing or writing them.
- :doc:`plugins/convert`: Add a config option to disable writing metadata to
converted files.
- :doc:`plugins/discogs`: New config option `strip_disambiguation` to toggle
stripping discogs numeric disambiguation on artist and label fields.
- :doc:`plugins/discogs`: New config option
:conf:`plugins.discogs:strip_disambiguation` to toggle stripping discogs
numeric disambiguation on artist and label fields.
- :doc:`plugins/discogs` Added support for featured artists. :bug:`6038`
- :doc:`plugins/discogs` New configuration option `featured_string` to change
the default string used to join featured artists. The default string is
`Feat.`.
- :doc:`plugins/discogs` New configuration option
:conf:`plugins.discogs:featured_string` to change the default string used to
join featured artists. The default string is `Feat.`.
- :doc:`plugins/discogs` Support for `artist_credit` in Discogs tags.
:bug:`3354`
- :doc:`plugins/discogs` Support for name variations and config options to
@ -43,11 +93,14 @@ Bug fixes:
- :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from
artists but not labels. :bug:`5366`
- :doc:`plugins/chroma` :doc:`plugins/bpsync` Fix plugin loading issue caused by
an import of another :class:`beets.plugins.BeetsPlugin` class. :bug:`6033`
an import of another |BeetsPlugin| class. :bug:`6033`
- :doc:`/plugins/fromfilename`: Fix :bug:`5218`, improve the code (refactor
regexps, allow for more cases, add some logging), add tests.
For packagers:
- Metadata source plugins: Fixed data source penalty calculation that was
incorrectly applied during import matching. The
:conf:`plugins.index:source_weight` configuration option has been renamed to
:conf:`plugins.index:data_source_mismatch_penalty` to better reflect its
purpose. :bug:`6066`
Other changes:
@ -67,12 +120,22 @@ Other changes:
disambiguation stripping.
- When installing ``beets`` via git or locally the version string now reflects
the current git branch and commit hash. :bug:`4448`
- :ref:`match-config`: ``match.distance_weights.source`` configuration has been
renamed to ``match.distance_weights.data_source`` for consistency with the
name of the field it refers to.
For developers and plugin authors:
- Typing improvements in ``beets/logging.py``: ``getLogger`` now returns
``BeetsLogger`` when called with a name, or ``RootLogger`` when called without
a name.
- The ``track_distance()`` and ``album_distance()`` methods have been removed
from ``MetadataSourcePlugin``. Distance calculation for data source mismatches
is now handled automatically by the core matching logic. This change
simplifies the plugin architecture and fixes incorrect penalty calculations.
:bug:`6066`
- Metadata source plugins are now registered globally when instantiated, which
makes their handling slightly more efficient.
2.4.0 (September 13, 2025)
--------------------------
@ -83,12 +146,13 @@ New features:
separate plugin. The default :ref:`plugins-config` includes ``musicbrainz``,
but if you've customized your ``plugins`` list in your configuration, you'll
need to explicitly add ``musicbrainz`` to continue using this functionality.
Configuration option ``musicbrainz.enabled`` has thus been deprecated.
:bug:`2686` :bug:`4605`
Configuration option :conf:`plugins.musicbrainz:enabled` has thus been
deprecated. :bug:`2686` :bug:`4605`
- :doc:`plugins/web`: Show notifications when a track plays. This uses the Media
Session API to customize media notifications.
- :doc:`plugins/discogs`: Add configurable ``search_limit`` option to limit the
number of results returned by the Discogs metadata search queries.
- :doc:`plugins/discogs`: Add configurable :conf:`plugins.discogs:search_limit`
option to limit the number of results returned by the Discogs metadata search
queries.
- :doc:`plugins/discogs`: Implement ``track_for_id`` method to allow retrieving
singletons by their Discogs ID. :bug:`4661`
- :doc:`plugins/replace`: Add new plugin.
@ -103,12 +167,13 @@ New features:
be played for it to be counted as played instead of skipped.
- :doc:`plugins/web`: Display artist and album as part of the search results.
- :doc:`plugins/spotify` :doc:`plugins/deezer`: Add new configuration option
``search_limit`` to limit the number of results returned by search queries.
:conf:`plugins.index:search_limit` to limit the number of results returned by
search queries.
Bug fixes:
- :doc:`plugins/musicbrainz`: fix regression where user configured
``extra_tags`` have been read incorrectly. :bug:`5788`
:conf:`plugins.musicbrainz:extra_tags` have been read incorrectly. :bug:`5788`
- tests: Fix library tests failing on Windows when run from outside ``D:/``.
:bug:`5802`
- Fix an issue where calling ``Library.add`` would cause the ``database_change``
@ -140,9 +205,10 @@ Bug fixes:
For packagers:
- Optional ``extra_tags`` parameter has been removed from
``BeetsPlugin.candidates`` method signature since it is never passed in. If
you override this method in your plugin, feel free to remove this parameter.
- Optional :conf:`plugins.musicbrainz:extra_tags` parameter has been removed
from ``BeetsPlugin.candidates`` method signature since it is never passed in.
If you override this method in your plugin, feel free to remove this
parameter.
- Loosened ``typing_extensions`` dependency in pyproject.toml to apply to every
python version.
@ -153,8 +219,8 @@ For plugin developers:
art sources might need to be adapted.
- We split the responsibilities of plugins into two base classes
1. :class:`beets.plugins.BeetsPlugin` is the base class for all plugins, any
plugin needs to inherit from this class.
1. |BeetsPlugin| is the base class for all plugins, any plugin needs to
inherit from this class.
2. :class:`beets.metadata_plugin.MetadataSourcePlugin` allows plugins to act
like metadata sources. E.g. used by the MusicBrainz plugin. All plugins in
the beets repo are opted into this class where applicable. If you are
@ -498,8 +564,9 @@ New features:
:bug:`4348`
- Create the parental directories for database if they do not exist. :bug:`3808`
:bug:`4327`
- :ref:`musicbrainz-config`: a new :ref:`musicbrainz.enabled` option allows
disabling the MusicBrainz metadata source during the autotagging process
- :ref:`musicbrainz-config`: a new :conf:`plugins.musicbrainz:enabled` option
allows disabling the MusicBrainz metadata source during the autotagging
process
- :doc:`/plugins/kodiupdate`: Now supports multiple kodi instances :bug:`4101`
- Add the item fields ``bitrate_mode``, ``encoder_info`` and
``encoder_settings``.
@ -532,8 +599,8 @@ New features:
:bug:`4561` :bug:`4600`
- :ref:`musicbrainz-config`: MusicBrainz release pages often link to related
metadata sources like Discogs, Bandcamp, Spotify, Deezer and Beatport. When
enabled via the :ref:`musicbrainz.external_ids` options, release ID's will be
extracted from those URL's and imported to the library. :bug:`4220`
enabled via the :conf:`plugins.musicbrainz:external_ids` options, release ID's
will be extracted from those URL's and imported to the library. :bug:`4220`
- :doc:`/plugins/convert`: Add support for generating m3u8 playlists together
with converted media files. :bug:`4373`
- Fetch the ``release_group_title`` field from MusicBrainz. :bug:`4809`
@ -887,8 +954,9 @@ Other new things:
- ``beet remove`` now also allows interactive selection of items from the query,
similar to ``beet modify``.
- Enable HTTPS for MusicBrainz by default and add configuration option ``https``
for custom servers. See :ref:`musicbrainz-config` for more details.
- Enable HTTPS for MusicBrainz by default and add configuration option
:conf:`plugins.musicbrainz:https` for custom servers. See
:ref:`musicbrainz-config` for more details.
- :doc:`/plugins/mpdstats`: Add a new ``strip_path`` option to help build the
right local path from MPD information.
- :doc:`/plugins/convert`: Conversion can now parallelize conversion jobs on
@ -908,8 +976,8 @@ Other new things:
server.
- :doc:`/plugins/subsonicupdate`: The plugin now automatically chooses between
token- and password-based authentication based on the server version.
- A new :ref:`extra_tags` configuration option lets you use more metadata in
MusicBrainz queries to further narrow the search.
- A new :conf:`plugins.musicbrainz:extra_tags` configuration option lets you use
more metadata in MusicBrainz queries to further narrow the search.
- A new :doc:`/plugins/fish` adds `Fish shell`_ tab autocompletion to beets.
- :doc:`plugins/fetchart` and :doc:`plugins/embedart`: Added a new ``quality``
option that controls the quality of the image output when the image is
@ -963,9 +1031,9 @@ Other new things:
(and now deprecated) separate ``host``, ``port``, and ``contextpath`` config
options. As a consequence, the plugin can now talk to Subsonic over HTTPS.
Thanks to :user:`jef`. :bug:`3449`
- :doc:`/plugins/discogs`: The new ``index_tracks`` option enables incorporation
of work names and intra-work divisions into imported track titles. Thanks to
:user:`cole-miller`. :bug:`3459`
- :doc:`/plugins/discogs`: The new :conf:`plugins.discogs:index_tracks` option
enables incorporation of work names and intra-work divisions into imported
track titles. Thanks to :user:`cole-miller`. :bug:`3459`
- :doc:`/plugins/web`: The query API now interprets backslashes as path
separators to support path queries. Thanks to :user:`nmeum`. :bug:`3567`
- ``beet import`` now handles tar archives with bzip2 or gzip compression.
@ -979,9 +1047,9 @@ Other new things:
:user:`logan-arens`. :bug:`2947`
- There is a new ``--plugins`` (or ``-p``) CLI flag to specify a list of plugins
to load.
- A new :ref:`genres` option fetches genre information from MusicBrainz. This
functionality depends on functionality that is currently unreleased in the
python-musicbrainzngs_ library: see PR `#266
- A new :conf:`plugins.musicbrainz:genres` option fetches genre information from
MusicBrainz. This functionality depends on functionality that is currently
unreleased in the python-musicbrainzngs_ library: see PR `#266
<https://github.com/alastair/python-musicbrainzngs/pull/266>`_. Thanks to
:user:`aereaux`.
- :doc:`/plugins/replaygain`: Analysis now happens in parallel using the
@ -1021,9 +1089,10 @@ Fixes:
:bug:`3867`
- :doc:`/plugins/web`: Fixed a small bug that caused the album art path to be
redacted even when ``include_paths`` option is set. :bug:`3866`
- :doc:`/plugins/discogs`: Fixed a bug with the ``index_tracks`` option that
sometimes caused the index to be discarded. Also, remove the extra semicolon
that was added when there is no index track.
- :doc:`/plugins/discogs`: Fixed a bug with the
:conf:`plugins.discogs:index_tracks` option that sometimes caused the index to
be discarded. Also, remove the extra semicolon that was added when there is no
index track.
- :doc:`/plugins/subsonicupdate`: The API client was using the ``POST`` method
rather the ``GET`` method. Also includes better exception handling, response
parsing, and tests.
@ -2639,9 +2708,9 @@ Major new features and bigger changes:
analysis tool. Thanks to :user:`jmwatte`. :bug:`1343`
- A new ``filesize`` field on items indicates the number of bytes in the file.
:bug:`1291`
- A new :ref:`search_limit` configuration option allows you to specify how many
search results you wish to see when looking up releases at MusicBrainz during
import. :bug:`1245`
- A new :conf:`plugins.index:search_limit` configuration option allows you to
specify how many search results you wish to see when looking up releases at
MusicBrainz during import. :bug:`1245`
- The importer now records the data source for a match in a new flexible
attribute ``data_source`` on items and albums. :bug:`1311`
- The colors used in the terminal interface are now configurable via the new
@ -5037,7 +5106,7 @@ BPD). To "upgrade" an old database, you can use the included ``albumify`` plugin
list of plugin names) and ``pluginpath`` (a colon-separated list of
directories to search beyond ``sys.path``). Plugins are just Python modules
under the ``beetsplug`` namespace package containing subclasses of
``beets.plugins.BeetsPlugin``. See `the beetsplug directory`_ for examples or
|BeetsPlugin|. See `the beetsplug directory`_ for examples or
:doc:`/plugins/index` for instructions.
- As a consequence of adding album art, the database was significantly
refactored to keep track of some information at an album (rather than item)

View file

@ -6,6 +6,11 @@
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
import sys
from pathlib import Path
# Add custom extensions directory to path
sys.path.insert(0, str(Path(__file__).parent / "extensions"))
project = "beets"
AUTHOR = "Adrian Sampson"
@ -13,8 +18,8 @@ copyright = "2016, Adrian Sampson"
master_doc = "index"
language = "en"
version = "2.4"
release = "2.4.0"
version = "2.5"
release = "2.5.1"
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
@ -23,13 +28,17 @@ extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.autosummary",
"sphinx.ext.extlinks",
"sphinx.ext.viewcode",
"sphinx_design",
"sphinx_copybutton",
"conf",
]
autosummary_generate = True
exclude_patterns = ["_build"]
templates_path = ["_templates"]
source_suffix = {".rst": "restructuredtext", ".md": "markdown"}
pygments_style = "sphinx"
# External links to the bug tracker and other sites.
@ -79,6 +88,7 @@ man_pages = [
rst_epilog = """
.. |Album| replace:: :class:`~beets.library.models.Album`
.. |AlbumInfo| replace:: :class:`beets.autotag.hooks.AlbumInfo`
.. |BeetsPlugin| replace:: :class:`beets.plugins.BeetsPlugin`
.. |ImportSession| replace:: :class:`~beets.importer.session.ImportSession`
.. |ImportTask| replace:: :class:`~beets.importer.tasks.ImportTask`
.. |Item| replace:: :class:`~beets.library.models.Item`

View file

@ -95,9 +95,9 @@ starting points include:
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**.
Older metadata plugins that extend |BeetsPlugin| should be migrated to
:py:class:`MetadataSourcePlugin`. Legacy support will be removed in **beets
v3.0.0**.
.. seealso::

View file

@ -40,8 +40,8 @@ or your plugin subpackage
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:
extend the |BeetsPlugin| abstract base class [2]_ . For instance, a minimal
plugin without any functionality would look like this:
.. code-block:: python
@ -52,6 +52,12 @@ instance, a minimal plugin without any functionality would look like this:
class MyAwesomePlugin(BeetsPlugin):
pass
.. attention::
If your plugin is composed of intermediate |BeetsPlugin| subclasses, make
sure that your plugin is defined *last* in the namespace. We only load the
last subclass of |BeetsPlugin| we find in your plugin namespace.
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

View file

@ -13,7 +13,7 @@ str.format-style string formatting. So you can write logging calls like this:
.. _pep 3101: https://www.python.org/dev/peps/pep-3101/
.. _standard python logging module: https://docs.python.org/2/library/logging.html
.. _standard python logging module: https://docs.python.org/3/library/logging.html
When beets is in verbose mode, plugin messages are prefixed with the plugin name
to make them easier to see.

142
docs/extensions/conf.py Normal file
View file

@ -0,0 +1,142 @@
"""Sphinx extension for simple configuration value documentation."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar
from docutils import nodes
from docutils.parsers.rst import directives
from sphinx import addnodes
from sphinx.directives import ObjectDescription
from sphinx.domains import Domain, ObjType
from sphinx.roles import XRefRole
from sphinx.util.nodes import make_refnode
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from docutils.nodes import Element
from docutils.parsers.rst.states import Inliner
from sphinx.addnodes import desc_signature, pending_xref
from sphinx.application import Sphinx
from sphinx.builders import Builder
from sphinx.environment import BuildEnvironment
from sphinx.util.typing import ExtensionMetadata, OptionSpec
class Conf(ObjectDescription[str]):
"""Directive for documenting a single configuration value."""
option_spec: ClassVar[OptionSpec] = {
"default": directives.unchanged,
}
def handle_signature(self, sig: str, signode: desc_signature) -> str:
"""Process the directive signature (the config name)."""
signode += addnodes.desc_name(sig, sig)
# Add default value if provided
if "default" in self.options:
signode += nodes.Text(" ")
default_container = nodes.inline("", "")
default_container += nodes.Text("(default: ")
default_container += nodes.literal("", self.options["default"])
default_container += nodes.Text(")")
signode += default_container
return sig
def add_target_and_index(
self, name: str, sig: str, signode: desc_signature
) -> None:
"""Add cross-reference target and index entry."""
target = f"conf-{name}"
if target not in self.state.document.ids:
signode["ids"].append(target)
self.state.document.note_explicit_target(signode)
# A unique full name which includes the document name
index_name = f"{self.env.docname.replace('/', '.')}:{name}"
# Register with the conf domain
domain = self.env.get_domain("conf")
domain.data["objects"][index_name] = (self.env.docname, target)
# Add to index
self.indexnode["entries"].append(
("single", f"{name} (configuration value)", target, "", None)
)
class ConfDomain(Domain):
"""Domain for simple configuration values."""
name = "conf"
label = "Simple Configuration"
object_types = {"conf": ObjType("conf", "conf")}
directives = {"conf": Conf}
roles = {"conf": XRefRole()}
initial_data: dict[str, Any] = {"objects": {}}
def get_objects(self) -> Iterable[tuple[str, str, str, str, str, int]]:
"""Return an iterable of object tuples for the inventory."""
for name, (docname, targetname) in self.data["objects"].items():
# Remove the document name prefix for display
display_name = name.split(":")[-1]
yield (name, display_name, "conf", docname, targetname, 1)
def resolve_xref(
self,
env: BuildEnvironment,
fromdocname: str,
builder: Builder,
typ: str,
target: str,
node: pending_xref,
contnode: Element,
) -> Element | None:
if entry := self.data["objects"].get(target):
docname, targetid = entry
return make_refnode(
builder, fromdocname, docname, targetid, contnode
)
return None
# sphinx.util.typing.RoleFunction
def conf_role(
name: str,
rawtext: str,
text: str,
lineno: int,
inliner: Inliner,
/,
options: dict[str, Any] | None = None,
content: Sequence[str] = (),
) -> tuple[list[nodes.Node], list[nodes.system_message]]:
"""Role for referencing configuration values."""
node = addnodes.pending_xref(
"",
refdomain="conf",
reftype="conf",
reftarget=text,
refwarn=True,
**(options or {}),
)
node += nodes.literal(text, text.split(":")[-1])
return [node], []
def setup(app: Sphinx) -> ExtensionMetadata:
app.add_domain(ConfDomain)
# register a top-level directive so users can use ".. conf:: ..."
app.add_directive("conf", Conf)
# Register role with short name
app.add_role("conf", conf_role)
return {
"version": "0.1",
"parallel_read_safe": True,
"parallel_write_safe": True,
}

View file

@ -163,7 +163,7 @@ documentation </dev/index>` pages.
.. _bugs:
…report a bug in beets?
~~~~~~~~~~~~~~~~~~~~~~~
-----------------------
We use the `issue tracker`_ on GitHub where you can `open a new ticket`_. Please
follow these guidelines when reporting an issue:
@ -171,7 +171,7 @@ follow these guidelines when reporting an issue:
- Most importantly: if beets is crashing, please `include the traceback
<https://imgur.com/jacoj>`__. Tracebacks can be more readable if you put them
in a pastebin (e.g., `Gist <https://gist.github.com/>`__ or `Hastebin
<https://hastebin.com/>`__), especially when communicating over IRC or email.
<https://hastebin.com/>`__), especially when communicating over IRC.
- Turn on beets' debug output (using the -v option: for example, ``beet -v
import ...``) and include that with your bug report. Look through this verbose
output for any red flags that might point to the problem.

View file

@ -9,5 +9,6 @@ guide.
:maxdepth: 1
main
installation
tagger
advanced

View file

@ -0,0 +1,179 @@
Installation
============
Beets requires `Python 3.9 or later`_. You can install it using package
managers, pipx_, pip_ or by using package managers.
.. _python 3.9 or later: https://python.org/download/
Using ``pipx`` or ``pip``
-------------------------
We recommend installing with pipx_ as it isolates beets and its dependencies
from your system Python and other Python packages. This helps avoid dependency
conflicts and keeps your system clean.
.. <!-- start-quick-install -->
.. tab-set::
.. tab-item:: pipx
.. code-block:: console
pipx install beets
.. tab-item:: pip
.. code-block:: console
pip install beets
.. tab-item:: pip (user install)
.. code-block:: console
pip install --user beets
.. <!-- end-quick-install -->
If you don't have pipx_ installed, you can follow the instructions on the `pipx
installation page`_ to get it set up.
.. _pip: https://pip.pypa.io/en/
.. _pipx: https://pipx.pypa.io/stable
.. _pipx installation page: https://pipx.pypa.io/stable/installation/
Using a Package Manager
-----------------------
Depending on your operating system, you may be able to install beets using a
package manager. Here are some common options:
.. attention::
Package manager installations may not provide the latest version of beets.
Release cycles for package managers vary, and they may not always have the
most recent version of beets. If you want the latest features and fixes,
consider using pipx_ or pip_ as described above.
Additionally, installing external beets plugins may be surprisingly
difficult when using a package manager.
- On **Debian or Ubuntu**, depending on the version, beets is available as an
official package (`Debian details`_, `Ubuntu details`_), so try typing:
``apt-get install beets``. But the version in the repositories might lag
behind, so make sure you read the right version of these docs. If you want the
latest version, you can get everything you need to install with pip as
described below by running: ``apt-get install python-dev python-pip``
- On **Arch Linux**, `beets is in [extra] <arch extra_>`_, so just run ``pacman
-S beets``. (There's also a bleeding-edge `dev package <aur_>`_ in the AUR,
which will probably set your computer on fire.)
- On **Alpine Linux**, `beets is in the community repository <alpine package_>`_
and can be installed with ``apk add beets``.
- On **Void Linux**, `beets is in the official repository <void package_>`_ and
can be installed with ``xbps-install -S beets``.
- For **Gentoo Linux**, beets is in Portage as ``media-sound/beets``. Just run
``emerge beets`` to install. There are several USE flags available for
optional plugin dependencies.
- On **FreeBSD**, there's a `beets port <freebsd_>`_ at ``audio/beets``.
- On **OpenBSD**, there's a `beets port <openbsd_>`_ can be installed with
``pkg_add beets``.
- On **Fedora** 22 or later, there's a `DNF package`_ you can install with
``sudo dnf install beets beets-plugins beets-doc``.
- On **Solus**, run ``eopkg install beets``.
- On **NixOS**, there's a `package <nixos_>`_ you can install with ``nix-env -i
beets``.
- Using **MacPorts**, run ``port install beets`` or ``port install beets-full``
to include many third-party plugins.
.. _alpine package: https://pkgs.alpinelinux.org/package/edge/community/x86_64/beets
.. _arch extra: https://archlinux.org/packages/extra/any/beets/
.. _aur: https://aur.archlinux.org/packages/beets-git/
.. _debian details: https://tracker.debian.org/pkg/beets
.. _dnf package: https://packages.fedoraproject.org/pkgs/beets/
.. _freebsd: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets
.. _nixos: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets
.. _openbsd: http://openports.se/audio/beets
.. _ubuntu details: https://launchpad.net/ubuntu/+source/beets
.. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets
Installation FAQ
----------------
MacOS Installation
~~~~~~~~~~~~~~~~~~
**Q: I'm getting permission errors on macOS. What should I do?**
Due to System Integrity Protection on macOS 10.11+, you may need to install for
your user only:
.. code-block:: console
pip install --user beets
You might need to also add ``~/Library/Python/3.x/bin`` to your ``$PATH``.
Windows Installation
~~~~~~~~~~~~~~~~~~~~
**Q: What's the process for installing on Windows?**
Installing beets on Windows can be tricky. Following these steps might help you
get it right:
1. `Install Python`_ (check "Add Python to PATH" skip to 3)
2. Ensure Python is in your ``PATH`` (add if needed):
- Settings → System → About → Advanced system settings → Environment
Variables
- Edit "PATH" and add: `;C:\Python39;C:\Python39\Scripts`
- *Guide: [Adding Python to
PATH](https://realpython.com/add-python-to-path/)*
3. Now install beets by running: ``pip install beets``
4. You're all set! Type ``beet version`` in a new command prompt to verify the
installation.
**Bonus: Windows Context Menu Integration**
Windows users may also want to install a context menu item for importing files
into beets. Download the beets.reg_ file and open it in a text file to make sure
the paths to Python match your system. Then double-click the file add the
necessary keys to your registry. You can then right-click a directory and choose
"Import with beets".
.. _beets.reg: https://github.com/beetbox/beets/blob/master/extra/beets.reg
.. _install pip: https://pip.pypa.io/en/stable/installing/
.. _install python: https://python.org/download/
ARM Installation
~~~~~~~~~~~~~~~~
**Q: Can I run beets on a Raspberry Pi or other ARM device?**
Yes, but with some considerations: Beets on ARM devices is not recommended for
Linux novices. If you are comfortable with troubleshooting tools like ``pip``,
``make``, and binary dependencies (e.g. ``ffmpeg`` and ``ImageMagick``), you
will be fine. We have `notes for ARM`_ and an `older ARM reference`_. Beets is
generally developed on x86-64 based devices, and most plugins target that
platform as well.
.. _notes for arm: https://github.com/beetbox/beets/discussions/4910
.. _older arm reference: https://discourse.beets.io/t/diary-of-beets-on-arm-odroid-hc4-armbian/1993

View file

@ -1,322 +1,310 @@
Getting Started
===============
Welcome to beets_! This guide will help you begin using it to make your music
collection better.
Welcome to beets_! This guide will help get started with improving and
organizing your music collection.
.. _beets: https://beets.io/
Installing
----------
Quick Installation
------------------
You will need Python. Beets works on Python 3.8 or later.
Beets is distributed via PyPI_ and can be installed by most users with a single
command:
- **macOS** 11 (Big Sur) includes Python 3.8 out of the box. You can opt for a
more recent Python installing it via Homebrew_ (``brew install python3``).
There's also a MacPorts_ port. Run ``port install beets`` or ``port install
beets-full`` to include many third-party plugins.
- On **Debian or Ubuntu**, depending on the version, beets is available as an
official package (`Debian details`_, `Ubuntu details`_), so try typing:
``apt-get install beets``. But the version in the repositories might lag
behind, so make sure you read the right version of these docs. If you want the
latest version, you can get everything you need to install with pip as
described below by running: ``apt-get install python-dev python-pip``
- On **Arch Linux**, `beets is in [extra] <arch extra_>`_, so just run ``pacman
-S beets``. (There's also a bleeding-edge `dev package <aur_>`_ in the AUR,
which will probably set your computer on fire.)
- On **Alpine Linux**, `beets is in the community repository <alpine package_>`_
and can be installed with ``apk add beets``.
- On **Void Linux**, `beets is in the official repository <void package_>`_ and
can be installed with ``xbps-install -S beets``.
- For **Gentoo Linux**, beets is in Portage as ``media-sound/beets``. Just run
``emerge beets`` to install. There are several USE flags available for
optional plugin dependencies.
- On **FreeBSD**, there's a `beets port <freebsd_>`_ at ``audio/beets``.
- On **OpenBSD**, there's a `beets port <openbsd_>`_ can be installed with
``pkg_add beets``.
- For **Slackware**, there's a SlackBuild_ available.
- On **Fedora** 22 or later, there's a `DNF package`_ you can install with
``sudo dnf install beets beets-plugins beets-doc``.
- On **Solus**, run ``eopkg install beets``.
- On **NixOS**, there's a `package <nixos_>`_ you can install with ``nix-env -i
beets``.
.. include:: installation.rst
:start-after: <!-- start-quick-install -->
:end-before: <!-- end-quick-install -->
.. _alpine package: https://pkgs.alpinelinux.org/package/edge/community/x86_64/beets
.. admonition:: Need more installation options?
.. _arch extra: https://archlinux.org/packages/extra/any/beets/
Having trouble with the commands above? Looking for package manager
instructions? See the :doc:`complete installation guide
</guides/installation>` for:
.. _aur: https://aur.archlinux.org/packages/beets-git/
- Operating system specific instructions
- Package manager options
- Troubleshooting help
.. _debian details: https://tracker.debian.org/pkg/beets
.. _pypi: https://pypi.org/project/beets/
.. _dnf package: https://packages.fedoraproject.org/pkgs/beets/
Basic Configuration
-------------------
.. _freebsd: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets
Before using beets, you'll need a configuration file. This YAML file tells beets
where to store your music and how to organize it.
.. _macports: https://www.macports.org
While beets is highly configurable, you only need a few basic settings to get
started.
.. _nixos: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets
1. **Open the config file:**
.. code-block:: console
.. _openbsd: http://openports.se/audio/beets
beet config -e
.. _slackbuild: https://slackbuilds.org/repository/14.2/multimedia/beets/
This creates the file (if needed) and opens it in your default editor.
You can also find its location with ``beet config -p``.
2. **Add required settings:**
In the config file, set the ``directory`` option to the path where you
want beets to store your music files. Set the ``library`` option to the
path where you want beets to store its database file.
.. _ubuntu details: https://launchpad.net/ubuntu/+source/beets
.. code-block:: yaml
.. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets
directory: ~/music
library: ~/data/musiclibrary.db
3. **Choose your import style** (pick one):
Beets offers flexible import strategies to match your workflow. Choose
one of the following approaches and put one of the following in your
config file:
If you have pip_, just say ``pip install beets`` (or ``pip install --user
beets`` if you run into permissions problems).
.. tab-set::
To install without pip, download beets from `its PyPI page`_ and run ``python
setup.py install`` in the directory therein.
.. tab-item:: Copy Files (Default)
.. _its pypi page: https://pypi.org/project/beets/#files
This is the default configuration and assumes you want to start a new organized music folder (inside ``directory`` above). During import we will *copy* cleaned-up music into that empty folder.
.. _pip: https://pip.pypa.io
.. code-block:: yaml
The best way to upgrade beets to a new version is by running ``pip install -U
beets``. You may want to follow `@b33ts`_ on Twitter to hear about progress on
new versions.
import:
copy: yes # Copy files to new location
.. _@b33ts: https://twitter.com/b33ts
Installing by Hand on macOS 10.11 and Higher
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. tab-item:: Move Files
Starting with version 10.11 (El Capitan), macOS has a new security feature
called `System Integrity Protection`_ (SIP) that prevents you from modifying
some parts of the system. This means that some ``pip`` commands may fail with a
permissions error. (You probably *won't* run into this if you've installed
Python yourself with Homebrew_ or otherwise. You can also try MacPorts_.)
Start with a new empty directory, but *move* new music in instead of copying it (saving disk space).
If this happens, you can install beets for the current user only by typing ``pip
install --user beets``. If you do that, you might want to add
``~/Library/Python/3.6/bin`` to your ``$PATH``.
.. code-block:: yaml
.. _homebrew: https://brew.sh
import:
move: yes # Move files to new location
.. _system integrity protection: https://support.apple.com/en-us/HT204899
.. tab-item:: Use Existing Structure
Installing on Windows
~~~~~~~~~~~~~~~~~~~~~
Keep your current directory structure; importing should never move or copy files but instead just correct the tags on music. Make sure to point ``directory`` at the place where your music is currently stored.
Installing beets on Windows can be tricky. Following these steps might help you
get it right:
.. code-block:: yaml
1. If you don't have it, `install Python`_ (you want at least Python 3.8). The
installer should give you the option to "add Python to PATH." Check this box.
If you do that, you can skip the next step.
2. If you haven't done so already, set your ``PATH`` environment variable to
include Python and its scripts. To do so, open the "Settings" application,
then access the "System" screen, then access the "About" tab, and then hit
"Advanced system settings" located on the right side of the screen. This
should open the "System Properties" screen, then select the "Advanced" tab,
then hit the "Environmental Variables..." button, and then look for the PATH
variable in the table. Add the following to the end of the variable's value:
``;C:\Python38;C:\Python38\Scripts``. You may need to adjust these paths to
point to your Python installation.
3. Now install beets by running: ``pip install beets``
4. You're all set! Type ``beet`` at the command prompt to make sure everything's
in order.
import:
copy: no # Use files in place
Windows users may also want to install a context menu item for importing files
into beets. Download the beets.reg_ file and open it in a text file to make sure
the paths to Python match your system. Then double-click the file add the
necessary keys to your registry. You can then right-click a directory and choose
"Import with beets".
.. tab-item:: Read-Only Mode
Because I don't use Windows myself, I may have missed something. If you have
trouble or you have more detail to contribute here, please direct it to `the
mailing list`_.
Keep everything exactly as-is; only track metadata in database. (Corrected tags will still be stored in beets' database, and you can use them to do renaming or tag changes later.)
.. _beets.reg: https://github.com/beetbox/beets/blob/master/extra/beets.reg
.. code-block:: yaml
.. _get-pip.py: https://bootstrap.pypa.io/get-pip.py
import:
copy: no # Use files in place
write: no # Don't modify tags
4. **Add customization via plugins (optional):**
Beets comes with many plugins that extend its functionality. You can
enable plugins by adding a `plugins` section to your config file.
.. _install pip: https://pip.pypa.io/en/stable/installing/
We recommend adding at least one :ref:`Autotagger Plugin
<autotagger_extensions>` to help with fetching metadata during import.
For getting started, :doc:`MusicBrainz </plugins/musicbrainz>` is a good
choice.
.. _install python: https://python.org/download/
.. code-block:: yaml
Installing on ARM (Raspberry Pi and similar)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
plugins:
- musicbrainz # Example plugin for fetching metadata
- ... other plugins you want ...
Beets on ARM devices is not recommended for Linux novices. If you are
comfortable with light troubleshooting in tools like ``pip``, ``make``, and
beets' command-line binary dependencies (e.g. ``ffmpeg`` and ``ImageMagick``),
you will probably be okay on ARM devices like the Raspberry Pi. We have `notes
for ARM`_ and an `older ARM reference`_. Beets is generally developed on x86-64
based devices, and most plugins target that platform as well.
.. _notes for arm: https://github.com/beetbox/beets/discussions/4910
.. _older arm reference: https://discourse.beets.io/t/diary-of-beets-on-arm-odroid-hc4-armbian/1993
Configuring
-----------
You'll want to set a few basic options before you start using beets. The
:doc:`configuration </reference/config>` is stored in a text file. You can show
its location by running ``beet config -p``, though it may not exist yet. Run
``beet config -e`` to edit the configuration in your favorite text editor. The
file will start out empty, but here's good place to start:
::
directory: ~/music
library: ~/data/musiclibrary.db
Change that first path to a directory where you'd like to keep your music. Then,
for ``library``, choose a good place to keep a database file that keeps an index
of your music. (The config's format is YAML_. You'll want to configure your text
editor to use spaces, not real tabs, for indentation. Also, ``~`` means your
home directory in these paths, even on Windows.)
The default configuration assumes you want to start a new organized music folder
(that ``directory`` above) and that you'll *copy* cleaned-up music into that
empty folder using beets' ``import`` command (see below). But you can configure
beets to behave many other ways:
- Start with a new empty directory, but *move* new music in instead of copying
it (saving disk space). Put this in your config file:
::
import:
move: yes
- Keep your current directory structure; importing should never move or copy
files but instead just correct the tags on music. Put the line ``copy: no``
under the ``import:`` heading in your config file to disable any copying or
renaming. Make sure to point ``directory`` at the place where your music is
currently stored.
- Keep your current directory structure and *do not* correct files' tags: leave
files completely unmodified on your disk. (Corrected tags will still be stored
in beets' database, and you can use them to do renaming or tag changes later.)
Put this in your config file:
::
import:
copy: no
write: no
to disable renaming and tag-writing.
There are other configuration options you can set here, including the directory
and file naming scheme. See :doc:`/reference/config` for a full reference.
You can find a list of available plugins in the :doc:`plugins index
</plugins/index>`.
.. _yaml: https://yaml.org/
To check that you've set up your configuration how you want it, you can type
``beet version`` to see a list of enabled plugins or ``beet config`` to get a
complete listing of your current configuration.
To validate that you've set up your configuration and it is valid YAML, you can
type ``beet version`` to see a list of enabled plugins or ``beet config`` to get
a complete listing of your current configuration.
Importing Your Library
----------------------
.. dropdown:: Minimal configuration
The next step is to import your music files into the beets library database.
Because this can involve modifying files and moving them around, data loss is
always a possibility, so now would be a good time to make sure you have a recent
backup of all your music. We'll wait.
Here's a sample configuration file that includes the settings mentioned above:
There are two good ways to bring your existing library into beets. You can
either: (a) quickly bring all your files with all their current metadata into
beets' database, or (b) use beets' highly-refined autotagger to find canonical
metadata for every album you import. Option (a) is really fast, but option (b)
makes sure all your songs' tags are exactly right from the get-go. The point
about speed bears repeating: using the autotagger on a large library can take a
very long time, and it's an interactive process. So set aside a good chunk of
time if you're going to go that route. For more on the interactive tagging
process, see :doc:`tagger`.
.. code-block:: yaml
If you've got time and want to tag all your music right once and for all, do
this:
directory: ~/music
library: ~/data/musiclibrary.db
::
import:
move: yes # Move files to new location
# copy: no # Use files in place
# write: no # Don't modify tags
$ beet import /path/to/my/music
plugins:
- musicbrainz # Example plugin for fetching metadata
# - ... other plugins you want ...
(Note that by default, this command will *copy music into the directory you
specified above*. If you want to use your current directory structure, set the
``import.copy`` config option.) To take the fast, un-autotagged path, just say:
You can copy and paste this into your config file and modify it as needed.
::
.. admonition:: Ready for more?
$ beet import -A /my/huge/mp3/library
For a complete reference of all configuration options, see the
:doc:`configuration reference </reference/config>`.
Note that you just need to add ``-A`` for "don't autotag".
Importing Your Music
--------------------
Adding More Music
-----------------
Now you're ready to import your music into beets!
If you've ripped or... otherwise obtained some new music, you can add it with
the ``beet import`` command, the same way you imported your library. Like so:
.. important::
::
Importing can modify and move your music files. **Make sure you have a
recent backup** before proceeding.
$ beet import ~/some_great_album
Choose Your Import Method
~~~~~~~~~~~~~~~~~~~~~~~~~
This will attempt to autotag the new album (interactively) and add it to your
library. There are, of course, more options for this command---just type ``beet
help import`` to see what's available.
There are two good ways to bring your *existing* library into beets database.
.. tab-set::
.. tab-item:: Autotag (Recommended)
This method uses beets' autotagger to find canonical metadata for every album you import. It may take a while, especially for large libraries, and it's an interactive process. But it ensures all your songs' tags are exactly right from the get-go.
.. code-block:: console
beet import /a/chunk/of/my/library
.. warning::
The point about speed bears repeating: using the autotagger on a large library can take a
very long time, and it's an interactive process. So set aside a good chunk of
time if you're going to go that route.
We also recommend importing smaller batches of music at a time (e.g., a few albums) to make the process more manageable. For more on the interactive tagging
process, see :doc:`tagger`.
.. tab-item:: Quick Import
This method quickly brings all your files with all their current metadata into beets' database without any changes. It's really fast, but it doesn't clean up or correct any tags.
To use this method, run:
.. code-block:: console
beet import --noautotag /my/huge/mp3/library
The ``--noautotag`` / ``-A`` flag skips autotagging and uses your files' current metadata.
.. admonition:: More Import Options
The ``beet import`` command has many options to customize its behavior. For
a full list, type ``beet help import`` or see the :ref:`import command
reference <import-cmd>`.
Adding More Music Later
~~~~~~~~~~~~~~~~~~~~~~~
When you acquire new music, use the same ``beet import`` command to add it to
your library:
.. code-block:: console
beet import ~/new_totally_not_ripped_album
This will apply the same autotagging process to your new additions. For
alternative import behaviors, consult the options mentioned above.
Seeing Your Music
-----------------
If you want to query your music library, the ``beet list`` (shortened to ``beet
ls``) command is for you. You give it a :doc:`query string </reference/query>`,
which is formatted something like a Google search, and it gives you a list of
songs. Thus:
Once you've imported music into beets, you'll want to explore and query your
library. Beets provides several commands for searching, browsing, and getting
statistics about your collection.
::
Basic Searching
~~~~~~~~~~~~~~~
The ``beet list`` command (shortened to ``beet ls``) lets you search your music
library using :doc:`query string </reference/query>` similar to web searches:
.. code-block:: console
$ beet ls the magnetic fields
The Magnetic Fields - Distortion - Three-Way
The Magnetic Fields - Distortion - California Girls
The Magnetic Fields - Dist
The Magnetic Fields - Distortion - Old Fools
.. code-block:: console
$ beet ls hissing gronlandic
of Montreal - Hissing Fauna, Are You the Destroyer? - Gronlandic Edit
.. code-block:: console
$ beet ls bird
The Knife - The Knife - Bird
The Mae Shi - Terrorbird - Revelation Six
By default, search terms match against :ref:`common attributes <keywordquery>`
of songs, and multiple terms are combined with AND logic (a track must match
*all* criteria).
Searching Specific Fields
~~~~~~~~~~~~~~~~~~~~~~~~~
To narrow a search term to a particular metadata field, prefix the term with the
field name followed by a colon. For example, ``album:bird`` searches for "bird"
only in the "album" field of your songs. For more details, see
:doc:`/reference/query/`.
.. code-block:: console
$ beet ls album:bird
The Mae Shi - Terrorbird - Revelation Six
By default, a search term will match any of a handful of :ref:`common attributes
<keywordquery>` of songs. (They're also implicitly joined by ANDs: a track must
match *all* criteria in order to match the query.) To narrow a search term to a
particular metadata field, just put the field before the term, separated by a :
character. So ``album:bird`` only looks for ``bird`` in the "album" field of
your songs. (Need to know more? :doc:`/reference/query/` will answer all your
questions.)
This searches only the ``album`` field for the term ``bird``.
Searching for Albums
~~~~~~~~~~~~~~~~~~~~
The ``beet list`` command also has an ``-a`` option, which searches for albums
instead of songs:
::
.. code-block:: console
$ beet ls -a forever
Bon Iver - For Emma, Forever Ago
Freezepop - Freezepop Forever
Custom Output Formatting
~~~~~~~~~~~~~~~~~~~~~~~~
There's also an ``-f`` option (for *format*) that lets you specify what gets
displayed in the results of a search:
::
.. code-block:: console
$ beet ls -a forever -f "[$format] $album ($year) - $artist - $title"
[MP3] For Emma, Forever Ago (2009) - Bon Iver - Flume
[AAC] Freezepop Forever (2011) - Freezepop - Harebrained Scheme
In the format option, field references like ``$format`` and ``$year`` are filled
in with data from each result. You can see a full list of available fields by
running ``beet fields``.
In the format string, field references like ``$format``, ``$year``, ``$album``,
etc., are replaced with data from each result.
Beets also has a ``stats`` command, just in case you want to see how much music
you have:
.. dropdown:: Available fields for formatting
::
To see all available fields you can use in custom formats, run:
.. code-block:: console
beet fields
This will display a comprehensive list of metadata fields available for your music.
Library Statistics
~~~~~~~~~~~~~~~~~~
Beets can also show you statistics about your music collection:
.. code-block:: console
$ beet stats
Tracks: 13019
@ -325,31 +313,107 @@ you have:
Artists: 548
Albums: 1094
.. admonition:: Ready for more advanced queries?
The ``beet list`` command has many additional options for sorting, limiting
results, and more complex queries. For a complete reference, run:
.. code-block:: console
beet help list
Or see the :ref:`list command reference <list-cmd>`.
Keep Playing
------------
This is only the beginning of your long and prosperous journey with beets. To
keep learning, take a look at :doc:`advanced` for a sampling of what else is
possible. You'll also want to glance over the :doc:`/reference/cli` page for a
more detailed description of all of beets' functionality. (Like deleting music!
That's important.)
Congratulations! You've now mastered the basics of beets. But this is only the
beginning, beets has many more powerful features to explore.
Also, check out :doc:`beets' plugins </plugins/index>`. The real power of beets
is in its extensibility---with plugins, beets can do almost anything for your
music collection.
Continue Your Learning Journey
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can always get help using the ``beet help`` command. The plain ``beet help``
command lists all the available commands; then, for example, ``beet help
import`` gives more specific help about the ``import`` command.
*I was there to push people beyond what's expected of them.*
If you need more of a walkthrough, you can read an illustrated one `on the beets
blog <https://beets.io/blog/walkthrough.html>`_.
.. grid:: 2
:gutter: 3
Please let us know what you think of beets via `the discussion board`_ or
Mastodon_.
.. grid-item-card:: :octicon:`zap` Advanced Techniques
:link: advanced
:link-type: doc
.. _mastodon: https://fosstodon.org/@beets
Explore sophisticated beets workflows including:
.. _the discussion board: https://github.com/beetbox/beets/discussions
- Advanced tagging strategies
- Complex import scenarios
- Custom metadata management
- Workflow automation
.. _the mailing list: https://groups.google.com/group/beets-users
.. grid-item-card:: :octicon:`terminal` Command Reference
:link: /reference/cli
:link-type: doc
Comprehensive guide to all beets commands:
- Complete command syntax
- All available options
- Usage examples
- **Important operations like deleting music**
.. grid-item-card:: :octicon:`plug` Plugin Ecosystem
:link: /plugins/index
:link-type: doc
Discover beets' true power through plugins:
- Metadata fetching from multiple sources
- Audio analysis and processing
- Streaming service integration
- Custom export formats
.. grid-item-card:: :octicon:`question` Illustrated Walkthrough
:link: https://beets.io/blog/walkthrough.html
:link-type: url
Visual, step-by-step guide covering:
- Real-world import examples
- Screenshots of interactive tagging
- Common workflow patterns
- Troubleshooting tips
.. admonition:: Need Help?
Remember you can always use ``beet help`` to see all available commands, or
``beet help [command]`` for detailed help on specific commands.
Join the Community
~~~~~~~~~~~~~~~~~~
We'd love to hear about your experience with beets!
.. grid:: 2
:gutter: 2
.. grid-item-card:: :octicon:`comment-discussion` Discussion Board
:link: https://github.com/beetbox/beets/discussions
:link-type: url
- Ask questions
- Share tips and tricks
- Discuss feature ideas
- Get help from other users
.. grid-item-card:: :octicon:`git-pull-request` Developer Resources
:link: /dev/index
:link-type: doc
- Contribute code
- Report issues
- Review pull requests
- Join development discussions
.. admonition:: Found a Bug?
If you encounter any issues, please report them on our `GitHub Issues page
<https://github.com/beetbox/beets/issues>`_.

View file

@ -311,5 +311,3 @@ If we haven't made the process clear, please post on `the discussion board`_ and
we'll try to improve this guide.
.. _the discussion board: https://github.com/beetbox/beets/discussions/
.. _the mailing list: https://groups.google.com/group/beets-users

View file

@ -13,9 +13,8 @@ Then you can get a more detailed look at beets' features in the
be interested in exploring the :doc:`plugins </plugins/index>`.
If you still need help, you can drop by the ``#beets`` IRC channel on
Libera.Chat, drop by `the discussion board`_, send email to `the mailing list`_,
or `file a bug`_ in the issue tracker. Please let us know where you think this
documentation can be improved.
Libera.Chat, drop by `the discussion board`_ or `file a bug`_ in the issue
tracker. Please let us know where you think this documentation can be improved.
.. _beets: https://beets.io/
@ -23,8 +22,6 @@ documentation can be improved.
.. _the discussion board: https://github.com/beetbox/beets/discussions/
.. _the mailing list: https://groups.google.com/group/beets-users
Contents
--------

View file

@ -27,20 +27,31 @@ Configuration
-------------
This plugin can be configured like other metadata source plugins as described in
:ref:`metadata-source-plugin-configuration`. In addition, the following
configuration options are provided.
:ref:`metadata-source-plugin-configuration`.
- **search_limit**: The maximum number of results to return from Deezer for each
search query. Default: ``5``.
Default
~~~~~~~
The default options should work as-is, but there are some options you can put in
config.yaml under the ``deezer:`` section:
.. code-block:: yaml
- **search_query_ascii**: If set to ``yes``, the search query will be converted
to ASCII before being sent to Deezer. Converting searches to ASCII can enhance
search results in some cases, but in general, it is not recommended. For
instance ``artist:deadmau5 album:4×4`` will be converted to ``artist:deadmau5
album:4x4`` (notice ``×!=x``). Default: ``no``.
deezer:
search_query_ascii: no
data_source_mismatch_penalty: 0.5
search_limit: 5
.. conf:: search_query_ascii
:default: no
If enabled, the search query will be converted to ASCII before being sent to
Deezer. Converting searches to ASCII can enhance search results in some cases,
but in general, it is not recommended. For instance, ``artist:deadmau5
album:4×4`` will be converted to ``artist:deadmau5 album:4x4`` (notice
``×!=x``).
.. include:: ./shared_metadata_source_config.rst
Commands
--------
The ``deezer`` plugin provides an additional command ``deezerupdate`` to update
the ``rank`` information from Deezer. The ``rank`` (ranges from 0 to 1M) is a

View file

@ -65,69 +65,99 @@ Configuration
This plugin can be configured like other metadata source plugins as described in
:ref:`metadata-source-plugin-configuration`.
There is one additional option in the ``discogs:`` section, ``index_tracks``.
Index tracks (see the `Discogs guidelines`_) along with headers, mark divisions
between distinct works on the same release or within works. When
``index_tracks`` is enabled:
.. code-block:: yaml
discogs:
index_tracks: yes
beets will incorporate the names of the divisions containing each track into the
imported track's title. Default: ``no``.
For example, importing `divisions album`_ would result in track names like:
.. code-block:: text
Messiah, Part I: No.1: Sinfony
Messiah, Part II: No.22: Chorus- Behold The Lamb Of God
Athalia, Act I, Scene I: Sinfonia
whereas with ``index_tracks`` disabled you'd get:
.. code-block:: text
No.1: Sinfony
No.22: Chorus- Behold The Lamb Of God
Sinfonia
This option is useful when importing classical music.
Other configurations available under ``discogs:`` are:
- **append_style_genre**: Appends the Discogs style (if found) to the genre tag.
This can be useful if you want more granular genres to categorize your music.
For example, a release in Discogs might have a genre of "Electronic" and a
style of "Techno": enabling this setting would set the genre to be
"Electronic, Techno" (assuming default separator of ``", "``) instead of just
"Electronic". Default: ``False``
- **separator**: How to join multiple genre and style values from Discogs into a
string. Default: ``", "``
- **search_limit**: The maximum number of results to return from Discogs. This
is useful if you want to limit the number of results returned to speed up
searches. Default: ``5``
- **strip_disambiguation**: Discogs uses strings like ``"(4)"`` to mark distinct
artists and labels with the same name. If you'd like to use the discogs
disambiguation in your tags, you can disable it. Default: ``True``
- **featured_string**: Configure the string used for noting featured artists.
Useful if you prefer ``Featuring`` or ``ft.``. Default: ``Feat.``
- **anv**: These configuration option are dedicated to handling Artist Name
Variations (ANVs). Sometimes a release credits artists differently compared to
the majority of their work. For example, "Basement Jaxx" may be credited as
"Tha Jaxx" or "The Basement Jaxx".You can select any combination of these
config options to control where beets writes and stores the variation credit.
The default, shown below, writes variations to the artist_credit field.
Default
~~~~~~~
.. code-block:: yaml
discogs:
apikey: REDACTED
apisecret: REDACTED
tokenfile: discogs_token.json
user_token:
index_tracks: no
append_style_genre: no
separator: ', '
strip_disambiguation: yes
featured_string: Feat.
anv:
artist_credit: True
artist: False
album_artist: False
artist_credit: yes
artist: no
album_artist: no
data_source_mismatch_penalty: 0.5
search_limit: 5
.. conf:: index_tracks
:default: no
Index tracks (see the `Discogs guidelines`_) along with headers, mark divisions
between distinct works on the same release or within works. When enabled,
beets will incorporate the names of the divisions containing each track into the
imported track's title.
For example, importing `divisions album`_ would result in track names like:
.. code-block:: text
Messiah, Part I: No.1: Sinfony
Messiah, Part II: No.22: Chorus- Behold The Lamb Of God
Athalia, Act I, Scene I: Sinfonia
whereas with ``index_tracks`` disabled you'd get:
.. code-block:: text
No.1: Sinfony
No.22: Chorus- Behold The Lamb Of God
Sinfonia
This option is useful when importing classical music.
.. conf:: append_style_genre
:default: no
Appends the Discogs style (if found) to the genre tag. This can be useful if
you want more granular genres to categorize your music. For example,
a release in Discogs might have a genre of "Electronic" and a style of
"Techno": enabling this setting would set the genre to be "Electronic,
Techno" (assuming default separator of ``", "``) instead of just
"Electronic".
.. conf:: separator
:default: ", "
How to join multiple genre and style values from Discogs into a string.
.. conf:: strip_disambiguation
:default: yes
Discogs uses strings like ``"(4)"`` to mark distinct artists and labels with
the same name. If you'd like to use the Discogs disambiguation in your tags,
you can disable this option.
.. conf:: featured_string
:default: Feat.
Configure the string used for noting featured artists. Useful if you prefer ``Featuring`` or ``ft.``.
.. conf:: anv
This configuration option is dedicated to handling Artist Name
Variations (ANVs). Sometimes a release credits artists differently compared to
the majority of their work. For example, "Basement Jaxx" may be credited as
"Tha Jaxx" or "The Basement Jaxx". You can select any combination of these
config options to control where beets writes and stores the variation credit.
The default, shown below, writes variations to the artist_credit field.
.. code-block:: yaml
discogs:
anv:
artist_credit: yes
artist: no
album_artist: no
.. include:: ./shared_metadata_source_config.rst
.. _discogs guidelines: https://support.discogs.com/hc/en-us/articles/360005055373-Database-Guidelines-12-Tracklisting#Index_Tracks_And_Headings

View file

@ -70,7 +70,7 @@ These options match the options from the `Python csv module`_.
.. _python csv module: https://docs.python.org/3/library/csv.html#csv-fmt-params
.. _python json module: https://docs.python.org/2/library/json.html#basic-usage
.. _python json module: https://docs.python.org/3/library/json.html#basic-usage
The default options look like this:

View file

@ -28,6 +28,8 @@ file. The available options are:
- **keep_in_artist**: Keep the featuring X part in the artist field. This can be
useful if you still want to be able to search for features in the artist
field. Default: ``no``.
- **custom_words**: List of additional words that will be treated as a marker
for artist features. Default: ``[]``.
Running Manually
----------------

View file

@ -47,21 +47,10 @@ some, you can use ``pip``'s "extras" feature to install the dependencies:
Using Metadata Source Plugins
-----------------------------
Some plugins provide sources for metadata in addition to MusicBrainz. These
plugins share the following configuration option:
We provide several :ref:`autotagger_extensions` that fetch metadata from online
databases. They share the following configuration options:
- **source_weight**: Penalty applied to matches during import. Set to 0.0 to
disable. Default: ``0.5``.
For example, to equally consider matches from Discogs and MusicBrainz add the
following to your configuration:
.. code-block:: yaml
plugins: musicbrainz discogs
discogs:
source_weight: 0.0
.. include:: ./shared_metadata_source_config.rst
.. toctree::
:hidden:

View file

@ -17,6 +17,9 @@ To use the ``musicbrainz`` plugin, enable it in your configuration (see
Configuration
-------------
This plugin can be configured like other metadata source plugins as described in
:ref:`metadata-source-plugin-configuration`.
Default
~~~~~~~
@ -27,7 +30,6 @@ Default
https: no
ratelimit: 1
ratelimit_interval: 1.0
search_limit: 5
extra_tags: []
genres: no
external_ids:
@ -37,122 +39,107 @@ Default
deezer: no
beatport: no
tidal: no
data_source_mismatch_penalty: 0.5
search_limit: 5
You can instruct beets to use `your own MusicBrainz database
<https://musicbrainz.org/doc/MusicBrainz_Server/Setup>`__ instead of the
.. conf:: host
:default: musicbrainz.org
`main server`_. Use the ``host``, ``https`` and ``ratelimit`` options under a
``musicbrainz:`` header, like so
The Web server hostname (and port, optionally) that will be contacted by beets.
You can use this to configure beets to use `your own MusicBrainz database
<https://musicbrainz.org/doc/MusicBrainz_Server/Setup>`__ instead of the
`main server`_.
.. code-block:: yaml
The server must have search indices enabled (see `Building search indexes`_).
musicbrainz:
host: localhost:5000
https: no
ratelimit: 100
Example:
The ``host`` key, of course, controls the Web server hostname (and port,
optionally) that will be contacted by beets (default: musicbrainz.org). The
``https`` key makes the client use HTTPS instead of HTTP. This setting applies
only to custom servers. The official MusicBrainz server always uses HTTPS.
(Default: no.) The server must have search indices enabled (see `Building search
indexes`_).
.. code-block:: yaml
The ``ratelimit`` option, an integer, controls the number of Web service
requests per second (default: 1). **Do not change the rate limit setting** if
you're using the main MusicBrainz server---on this public server, you're
limited_ to one request per second.
musicbrainz:
host: localhost:5000
.. conf:: https
:default: no
Makes the client use HTTPS instead of HTTP. This setting applies only to custom
servers. The official MusicBrainz server always uses HTTPS.
.. conf:: ratelimit
:default: 1
Controls the number of Web service requests per second.
**Do not change the rate limit setting** if you're using the main MusicBrainz
server---on this public server, you're limited_ to one request per second.
.. conf:: ratelimit_interval
:default: 1.0
The time interval (in seconds) for the rate limit.
.. conf:: enabled
:default: yes
.. deprecated:: 2.4 Add ``musicbrainz`` to the ``plugins`` list instead.
.. conf:: extra_tags
:default: []
By default, beets will use only the artist, album, and track count to query
MusicBrainz. Additional tags to be queried can be supplied with the
``extra_tags`` setting.
This setting should improve the autotagger results if the metadata with the
given tags match the metadata returned by MusicBrainz.
Note that the only tags supported by this setting are: ``barcode``,
``catalognum``, ``country``, ``label``, ``media``, and ``year``.
Example:
.. code-block:: yaml
musicbrainz:
extra_tags: [barcode, catalognum, country, label, media, year]
.. conf:: genres
:default: no
Use MusicBrainz genre tags to populate (and replace if it's already set) the
``genre`` tag. This will make it a list of all the genres tagged for the release
and the release-group on MusicBrainz, separated by "; " and sorted by the total
number of votes.
.. conf:: external_ids
**Default**
.. code-block:: yaml
musicbrainz:
external_ids:
discogs: no
spotify: no
bandcamp: no
beatport: no
deezer: no
tidal: no
Set any of the ``external_ids`` options to ``yes`` to enable the MusicBrainz
importer to look for links to related metadata sources. If such a link is
available the release ID will be extracted from the URL provided and imported to
the beets library.
The library fields of the corresponding :ref:`autotagger_extensions` are used to
save the data as flexible attributes (``discogs_album_id``, ``bandcamp_album_id``, ``spotify_album_id``,
``beatport_album_id``, ``deezer_album_id``, ``tidal_album_id``). On re-imports
existing data will be overwritten.
.. include:: ./shared_metadata_source_config.rst
.. _building search indexes: https://musicbrainz.org/doc/Development/Search_server_setup
.. _limited: https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting
.. _main server: https://musicbrainz.org/
.. _musicbrainz.enabled:
enabled
+++++++
.. deprecated:: 2.3 Add ``musicbrainz`` to the ``plugins`` list instead.
This option allows you to disable using MusicBrainz as a metadata source. This
applies if you use plugins that fetch data from alternative sources and should
make the import process quicker.
Default: ``yes``.
.. _search_limit:
search_limit
++++++++++++
The number of matches returned when sending search queries to the MusicBrainz
server.
Default: ``5``.
searchlimit
+++++++++++
.. deprecated:: 2.4 Use `search_limit`_.
.. _extra_tags:
extra_tags
++++++++++
By default, beets will use only the artist, album, and track count to query
MusicBrainz. Additional tags to be queried can be supplied with the
``extra_tags`` setting. For example
.. code-block:: yaml
musicbrainz:
extra_tags: [barcode, catalognum, country, label, media, year]
This setting should improve the autotagger results if the metadata with the
given tags match the metadata returned by MusicBrainz.
Note that the only tags supported by this setting are the ones listed in the
above example.
Default: ``[]``
.. _genres:
genres
++++++
Use MusicBrainz genre tags to populate (and replace if it's already set) the
``genre`` tag. This will make it a list of all the genres tagged for the release
and the release-group on MusicBrainz, separated by "; " and sorted by the total
number of votes. Default: ``no``
.. _musicbrainz.external_ids:
external_ids
++++++++++++
Set any of the ``external_ids`` options to ``yes`` to enable the MusicBrainz
importer to look for links to related metadata sources. If such a link is
available the release ID will be extracted from the URL provided and imported to
the beets library
.. code-block:: yaml
musicbrainz:
external_ids:
discogs: yes
spotify: yes
bandcamp: yes
beatport: yes
deezer: yes
tidal: yes
The library fields of the corresponding :ref:`autotagger_extensions` are used to
save the data (``discogs_albumid``, ``bandcamp_album_id``, ``spotify_album_id``,
``beatport_album_id``, ``deezer_album_id``, ``tidal_album_id``). On re-imports
existing data will be overwritten.
The default of all options is ``no``.

View file

@ -107,6 +107,15 @@ string, use ``$args`` to indicate where to insert them. For example:
indicates that you need to insert extra arguments before specifying the
playlist.
Some players require a different syntax. For example, with ``mpv`` the optional
``$playlist`` variable can be used to match the syntax of the ``--playlist``
option:
::
play:
command: mpv $args --playlist=$playlist
The ``--yes`` (or ``-y``) flag to the ``play`` command will skip the warning
message if you choose to play more items than the **warning_threshold** value
usually allows.
@ -123,4 +132,4 @@ until they are externally wiped could be an issue for privacy or storage
reasons. If this is the case for you, you might want to use the ``raw`` config
option described above.
.. _tempfile.tempdir: https://docs.python.org/2/library/tempfile.html#tempfile.tempdir
.. _tempfile.tempdir: https://docs.python.org/3/library/tempfile.html#tempfile.tempdir

View file

@ -0,0 +1,65 @@
.. _data_source_mismatch_penalty:
.. conf:: data_source_mismatch_penalty
:default: 0.5
Penalty applied when the data source of a
match candidate differs from the original source of your existing tracks. Any
decimal number between 0.0 and 1.0
This setting controls how much to penalize matches from different metadata
sources during import. The penalty is applied when beets detects that a match
candidate comes from a different data source than what appears to be the
original source of your music collection.
**Example configurations:**
.. code-block:: yaml
# Prefer MusicBrainz over Discogs when sources don't match
plugins: musicbrainz discogs
musicbrainz:
data_source_mismatch_penalty: 0.3 # Lower penalty = preferred
discogs:
data_source_mismatch_penalty: 0.8 # Higher penalty = less preferred
.. code-block:: yaml
# Do not penalise candidates from Discogs at all
plugins: musicbrainz discogs
musicbrainz:
data_source_mismatch_penalty: 0.5
discogs:
data_source_mismatch_penalty: 0.0
.. code-block:: yaml
# Disable cross-source penalties entirely
plugins: musicbrainz discogs
musicbrainz:
data_source_mismatch_penalty: 0.0
discogs:
data_source_mismatch_penalty: 0.0
.. tip::
The last configuration is equivalent to setting:
.. code-block:: yaml
match:
distance_weights:
data_source: 0.0 # Disable data source matching
.. conf:: source_weight
:default: 0.5
.. deprecated:: 2.5 Use `data_source_mismatch_penalty`_ instead.
.. conf:: search_limit
:default: 5
Maximum number of search results to return.

View file

@ -65,66 +65,84 @@ Configuration
-------------
This plugin can be configured like other metadata source plugins as described in
:ref:`metadata-source-plugin-configuration`. In addition, the following
configuration options are provided.
:ref:`metadata-source-plugin-configuration`.
The default options should work as-is, but there are some options you can put in
config.yaml under the ``spotify:`` section:
Default
~~~~~~~
- **mode**: One of the following:
- ``list``: Print out the playlist as a list of links. This list can then
be pasted in to a new or existing Spotify playlist.
- ``open``: This mode actually sends a link to your default browser with
instructions to open Spotify with the playlist you created. Until this
has been tested on all platforms, it will remain optional.
Default: ``list``.
- **region_filter**: A two-character country abbreviation, to limit results to
that market. Default: None.
- **show_failures**: List each lookup that does not return a Spotify ID (and
therefore cannot be added to a playlist). Default: ``no``.
- **tiebreak**: How to choose the track if there is more than one identical
result. For example, there might be multiple releases of the same album. The
options are ``popularity`` and ``first`` (to just choose the first match
returned). Default: ``popularity``.
- **regex**: An array of regex transformations to perform on the
track/album/artist fields before sending them to Spotify. Can be useful for
changing certain abbreviations, like ft. -> feat. See the examples below.
Default: None.
- **search_query_ascii**: If set to ``yes``, the search query will be converted
to ASCII before being sent to Spotify. Converting searches to ASCII can
enhance search results in some cases, but in general, it is not recommended.
For instance ``artist:deadmau5 album:4×4`` will be converted to
``artist:deadmau5 album:4x4`` (notice ``×!=x``). Default: ``no``.
- **search_limit**: The maximum number of results to return from Spotify for
each search query. Default: ``5``.
Here's an example:
::
.. code-block:: yaml
spotify:
source_weight: 0.7
mode: open
region_filter: US
show_failures: on
tiebreak: first
mode: list
region_filter:
show_failures: no
tiebreak: popularity
regex: []
search_query_ascii: no
client_id: REDACTED
client_secret: REDACTED
tokenfile: spotify_token.json
data_source_mismatch_penalty: 0.5
search_limit: 5
regex: [
{
field: "albumartist", # Field in the item object to regex.
search: "Something", # String to look for.
replace: "Replaced" # Replacement value.
},
{
field: "title",
search: "Something Else",
replace: "AlsoReplaced"
}
]
.. conf:: mode
:default: list
Controls how the playlist is output:
- ``list``: Print out the playlist as a list of links. This list can then
be pasted in to a new or existing Spotify playlist.
- ``open``: This mode actually sends a link to your default browser with
instructions to open Spotify with the playlist you created. Until this
has been tested on all platforms, it will remain optional.
.. conf:: region_filter
:default:
A two-character country abbreviation, to limit results to that market.
.. conf:: show_failures
:default: no
List each lookup that does not return a Spotify ID (and therefore cannot be
added to a playlist).
.. conf:: tiebreak
:default: popularity
How to choose the candidate if there is more than one identical result. For
example, there might be multiple releases of the same album.
- ``popularity``: pick the more popular candidate
- ``first``: pick the first candidate
.. conf:: regex
:default: []
An array of regex transformations to perform on the track/album/artist fields
before sending them to Spotify. Can be useful for changing certain
abbreviations, like ft. -> feat. For example:
.. code-block:: yaml
regex:
- field: albumartist
search: Something
replace: Replaced
- field: title
search: Something Else
replace: AlsoReplaced
.. conf:: search_query_ascii
:default: no
If enabled, the search query will be converted to ASCII before being sent to
Spotify. Converting searches to ASCII can enhance search results in some
cases, but in general, it is not recommended. For instance,
``artist:deadmau5 album:4×4`` will be converted to ``artist:deadmau5
album:4x4`` (notice ``×!=x``).
.. include:: ./shared_metadata_source_config.rst
Obtaining Track Popularity and Audio Features from Spotify
----------------------------------------------------------

View file

@ -31,6 +31,9 @@ to nullify and the conditions for nullifying them:
``keep_fields``---not both!
- To conditionally filter a field, use ``field: [regexp, regexp]`` to specify
regular expressions.
- Set ``omit_single_disc`` to ``True`` to omit writing the ``disc`` number for
albums with only a single disc (``disctotal == 1``). By default, beets will
number the disc even if the album contains only one disc in total.
- By default this plugin only affects files' tags; the beets database is left
unchanged. To update the tags in the database, set the ``update_database``
option to true.

View file

@ -77,10 +77,10 @@ pluginpath
~~~~~~~~~~
Directories to search for plugins. Each Python file or directory in a plugin
path represents a plugin and should define a subclass of :class:`BeetsPlugin`. A
plugin can then be loaded by adding the filename to the ``plugins``
configuration. The plugin path can either be a single string or a list of
strings---so, if you have multiple paths, format them as a YAML list like so:
path represents a plugin and should define a subclass of |BeetsPlugin|. A plugin
can then be loaded by adding the plugin name to the ``plugins`` configuration.
The plugin path can either be a single string or a list of strings---so, if you
have multiple paths, format them as a YAML list like so:
::
@ -376,7 +376,7 @@ terminal_encoding
~~~~~~~~~~~~~~~~~
The text encoding, as `known to Python
<https://docs.python.org/2/library/codecs.html#standard-encodings>`__, to use
<https://docs.python.org/3/library/codecs.html#standard-encodings>`__, to use
for messages printed to the standard output. It's also used to read messages
from the standard input. By default, this is determined automatically from the
locale environment variables.
@ -935,7 +935,7 @@ can be one of ``none``, ``low``, ``medium`` or ``strong``. When the maximum
recommendation is ``strong``, no "downgrading" occurs. The available penalty
names here are:
- source
- data_source
- artist
- album
- media

View file

@ -19,6 +19,8 @@ from packaging.version import Version, parse
from sphinx.ext import intersphinx
from typing_extensions import TypeAlias
from docs.conf import rst_epilog
BASE = Path(__file__).parent.parent.absolute()
PYPROJECT = BASE / "pyproject.toml"
CHANGELOG = BASE / "docs" / "changelog.rst"
@ -104,11 +106,21 @@ def create_rst_replacements() -> list[Replacement]:
plugins = "|".join(
r.split("/")[-1] for r in refs if r.startswith("plugins/")
)
explicit_replacements = dict(
line.removeprefix(".. ").split(" replace:: ")
for line in filter(None, rst_epilog.splitlines())
)
return [
# Replace Sphinx :ref: and :doc: directives by documentation URLs
# Replace explicitly defined substitutions from rst_epilog
# |BeetsPlugin| -> :class:`beets.plugins.BeetsPlugin`
(
r"\|\w[^ ]*\|",
lambda m: explicit_replacements.get(m[0], m[0]),
),
# Replace Sphinx directives by documentation URLs, e.g.,
# :ref:`/plugins/autobpm` -> [AutoBPM Plugin](DOCS/plugins/autobpm.html)
(
r":(?:ref|doc):`+(?:([^`<]+)<)?/?([\w./_-]+)>?`+",
r":(?:ref|doc|class|conf):`+(?:([^`<]+)<)?/?([\w.:/_-]+)>?`+",
lambda m: make_ref_link(m[2], m[1]),
),
# Convert command references to documentation URLs
@ -174,6 +186,12 @@ FILENAME_AND_UPDATE_TEXT: list[tuple[Path, UpdateVersionCallable]] = [
PYPROJECT,
lambda text, new: re.sub(r"(?<=\nversion = )[^\n]+", f'"{new}"', text),
),
(
BASE / "beets" / "__init__.py",
lambda text, new: re.sub(
r"(?<=__version__ = )[^\n]+", f'"{new}"', text
),
),
(CHANGELOG, update_changelog),
(BASE / "docs" / "conf.py", update_docs_config),
]

58
poetry.lock generated
View file

@ -3216,6 +3216,49 @@ docs = ["sphinxcontrib-websupport"]
lint = ["flake8 (>=6.0)", "importlib-metadata (>=6.0)", "mypy (==1.10.1)", "pytest (>=6.0)", "ruff (==0.5.2)", "sphinx-lint (>=0.9)", "tomli (>=2)", "types-docutils (==0.21.0.20240711)", "types-requests (>=2.30.0)"]
test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"]
[[package]]
name = "sphinx-copybutton"
version = "0.5.2"
description = "Add a copy button to each of your code cells."
optional = true
python-versions = ">=3.7"
files = [
{file = "sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd"},
{file = "sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e"},
]
[package.dependencies]
sphinx = ">=1.8"
[package.extras]
code-style = ["pre-commit (==2.12.1)"]
rtd = ["ipython", "myst-nb", "sphinx", "sphinx-book-theme", "sphinx-examples"]
[[package]]
name = "sphinx-design"
version = "0.6.1"
description = "A sphinx extension for designing beautiful, view size responsive web components."
optional = true
python-versions = ">=3.9"
files = [
{file = "sphinx_design-0.6.1-py3-none-any.whl", hash = "sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c"},
{file = "sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632"},
]
[package.dependencies]
sphinx = ">=6,<9"
[package.extras]
code-style = ["pre-commit (>=3,<4)"]
rtd = ["myst-parser (>=2,<4)"]
testing = ["defusedxml", "myst-parser (>=2,<4)", "pytest (>=8.3,<9.0)", "pytest-cov", "pytest-regressions"]
testing-no-myst = ["defusedxml", "pytest (>=8.3,<9.0)", "pytest-cov", "pytest-regressions"]
theme-furo = ["furo (>=2024.7.18,<2024.8.0)"]
theme-im = ["sphinx-immaterial (>=0.12.2,<0.13.0)"]
theme-pydata = ["pydata-sphinx-theme (>=0.15.2,<0.16.0)"]
theme-rtd = ["sphinx-rtd-theme (>=2.0,<3.0)"]
theme-sbt = ["sphinx-book-theme (>=1.1,<2.0)"]
[[package]]
name = "sphinx-lint"
version = "1.0.0"
@ -3430,6 +3473,17 @@ files = [
[package.dependencies]
types-html5lib = "*"
[[package]]
name = "types-docutils"
version = "0.22.2.20251006"
description = "Typing stubs for docutils"
optional = false
python-versions = ">=3.9"
files = [
{file = "types_docutils-0.22.2.20251006-py3-none-any.whl", hash = "sha256:1e61afdeb4fab4ae802034deea3e853ced5c9b5e1d156179000cb68c85daf384"},
{file = "types_docutils-0.22.2.20251006.tar.gz", hash = "sha256:c36c0459106eda39e908e9147bcff9dbd88535975cde399433c428a517b9e3b2"},
]
[[package]]
name = "types-flask-cors"
version = "6.0.0.20250520"
@ -3607,7 +3661,7 @@ beatport = ["requests-oauthlib"]
bpd = ["PyGObject"]
chroma = ["pyacoustid"]
discogs = ["python3-discogs-client"]
docs = ["pydata-sphinx-theme", "sphinx"]
docs = ["docutils", "pydata-sphinx-theme", "sphinx", "sphinx-copybutton", "sphinx-design"]
embedart = ["Pillow"]
embyupdate = ["requests"]
fetchart = ["Pillow", "beautifulsoup4", "langdetect", "requests"]
@ -3629,4 +3683,4 @@ web = ["flask", "flask-cors"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.9,<4"
content-hash = "faea27878ce1ca3f1335fd83e027b289351c51c73550bda72bf501a9c82166f7"
content-hash = "aedfeb1ac78ae0120855c6a7d6f35963c63cc50a8750142c95dd07ffd213683f"

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "beets"
version = "2.4.0"
version = "2.5.1"
description = "music tagger and library organizer"
authors = ["Adrian Sampson <adrian@radbox.org>"]
maintainers = ["Serene-Arc"]
@ -77,8 +77,11 @@ resampy = { version = ">=0.4.3", optional = true }
requests-oauthlib = { version = ">=0.6.1", optional = true }
soco = { version = "*", optional = true }
docutils = { version = ">=0.20.1", optional = true }
pydata-sphinx-theme = { version = "*", optional = true }
sphinx = { version = "*", optional = true }
sphinx-design = { version = ">=0.6.1", optional = true }
sphinx-copybutton = { version = ">=0.5.2", optional = true }
[tool.poetry.group.test.dependencies]
beautifulsoup4 = "*"
@ -107,6 +110,7 @@ sphinx-lint = ">=1.0.0"
[tool.poetry.group.typing.dependencies]
mypy = "*"
types-beautifulsoup4 = "*"
types-docutils = ">=0.22.2.20251006"
types-mock = "*"
types-Flask-Cors = "*"
types-Pillow = "*"
@ -129,7 +133,14 @@ beatport = ["requests-oauthlib"]
bpd = ["PyGObject"] # gobject-introspection, gstreamer1.0-plugins-base, python3-gst-1.0
chroma = ["pyacoustid"] # chromaprint or fpcalc
# convert # ffmpeg
docs = ["pydata-sphinx-theme", "sphinx", "sphinx-lint"]
docs = [
"docutils",
"pydata-sphinx-theme",
"sphinx",
"sphinx-lint",
"sphinx-design",
"sphinx-copybutton",
]
discogs = ["python3-discogs-client"]
embedart = ["Pillow"] # ImageMagick
embyupdate = ["requests"]
@ -156,18 +167,9 @@ web = ["flask", "flask-cors"]
[tool.poetry.scripts]
beet = "beets.ui:main"
[tool.poetry-dynamic-versioning]
enable = true
vcs = "git"
format = "{base}.dev{distance}+{commit}"
[tool.poetry-dynamic-versioning.files."beets/_version.py"]
persistent-substitution = true
[build-system]
requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]
build-backend = "poetry_dynamic_versioning.backend"
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.pipx-install]
poethepoet = ">=0.26"

View file

@ -10,24 +10,23 @@ from beets.autotag.distance import (
track_distance,
)
from beets.library import Item
from beets.metadata_plugins import MetadataSourcePlugin, get_penalty
from beets.plugins import BeetsPlugin
from beets.test.helper import ConfigMixin
_p = pytest.param
class TestDistance:
@pytest.fixture(scope="class")
def config(self):
return ConfigMixin().config
@pytest.fixture
def dist(self, config):
config["match"]["distance_weights"]["source"] = 2.0
@pytest.fixture(autouse=True, scope="class")
def setup_config(self):
config = ConfigMixin().config
config["match"]["distance_weights"]["data_source"] = 2.0
config["match"]["distance_weights"]["album"] = 4.0
config["match"]["distance_weights"]["medium"] = 2.0
Distance.__dict__["_weights"].cache = {}
@pytest.fixture
def dist(self):
return Distance()
def test_add(self, dist):
@ -103,7 +102,7 @@ class TestDistance:
assert dist["media"] == 1 / 6
def test_operators(self, dist):
dist.add("source", 0.0)
dist.add("data_source", 0.0)
dist.add("album", 0.5)
dist.add("medium", 0.25)
dist.add("medium", 0.75)
@ -162,10 +161,8 @@ class TestTrackDistance:
def test_track_distance(self, info, title, artist, expected_penalty):
item = Item(artist=artist, title=title)
assert (
bool(track_distance(item, info, incl_artist=True))
== expected_penalty
)
dist = track_distance(item, info, incl_artist=True)
assert bool(dist) == expected_penalty, dist._penalties
class TestAlbumDistance:
@ -297,3 +294,66 @@ class TestStringDistance:
string_dist("The ", "")
string_dist("(EP)", "(EP)")
string_dist(", An", "")
class TestDataSourceDistance:
MATCH = 0.0
MISMATCH = 0.125
@pytest.fixture(autouse=True)
def setup(self, monkeypatch, penalty, weight, multiple_data_sources):
monkeypatch.setitem(Distance._weights, "data_source", weight)
get_penalty.cache_clear()
class TestMetadataSourcePlugin(MetadataSourcePlugin):
def album_for_id(self, *args, **kwargs): ...
def track_for_id(self, *args, **kwargs): ...
def candidates(self, *args, **kwargs): ...
def item_candidates(self, *args, **kwargs): ...
# We use BeetsPlugin here to check if our compatibility layer
# for pre 2.4.0 MetadataPlugins is working as expected
# TODO: Replace BeetsPlugin with TestMetadataSourcePlugin in v3.0.0
with pytest.deprecated_call():
class OriginalPlugin(BeetsPlugin):
data_source = "Original"
class OtherPlugin(TestMetadataSourcePlugin):
@property
def data_source_mismatch_penalty(self):
return penalty
monkeypatch.setattr(
"beets.metadata_plugins.find_metadata_source_plugins",
lambda: (
[OriginalPlugin(), OtherPlugin()]
if multiple_data_sources
else [OtherPlugin()]
),
)
@pytest.mark.parametrize(
"item,info,penalty,weight,multiple_data_sources,expected_distance",
[
_p("Original", "Original", 0.5, 1.0, True, MATCH, id="match"),
_p("Original", "Other", 0.5, 1.0, True, MISMATCH, id="mismatch"),
_p("Other", "Original", 0.5, 1.0, True, MISMATCH, id="mismatch"),
_p("Original", "unknown", 0.5, 1.0, True, MISMATCH, id="mismatch-unknown"), # noqa: E501
_p("Original", None, 0.5, 1.0, True, MISMATCH, id="mismatch-no-info"), # noqa: E501
_p(None, "Other", 0.5, 1.0, True, MISMATCH, id="mismatch-no-original-multiple-sources"), # noqa: E501
_p(None, "Other", 0.5, 1.0, False, MATCH, id="match-no-original-but-single-source"), # noqa: E501
_p("unknown", "unknown", 0.5, 1.0, True, MATCH, id="match-unknown"),
_p("Original", "Other", 1.0, 1.0, True, 0.25, id="mismatch-max-penalty"), # noqa: E501
_p("Original", "Other", 0.5, 5.0, True, 0.3125, id="mismatch-high-weight"), # noqa: E501
_p("Original", "Other", 0.0, 1.0, True, MATCH, id="match-no-penalty"), # noqa: E501
_p("Original", "Other", 0.5, 0.0, True, MATCH, id="match-no-weight"), # noqa: E501
],
) # fmt: skip
def test_distance(self, item, info, expected_distance):
item = Item(data_source=item)
info = TrackInfo(data_source=info, title="")
dist = track_distance(item, info)
assert dist.distance == expected_distance

View file

@ -3,7 +3,9 @@ import os
import pytest
from beets.autotag.distance import Distance
from beets.dbcore.query import Query
from beets.util import cached_classproperty
def skip_marked_items(items: list[pytest.Item], marker_name: str, reason: str):
@ -41,3 +43,13 @@ def pytest_make_parametrize_id(config, val, argname):
return inspect.getsource(val).split("lambda")[-1][:30]
return repr(val)
def pytest_assertrepr_compare(op, left, right):
if isinstance(left, Distance) or isinstance(right, Distance):
return [f"Comparing Distance: {float(left)} {op} {float(right)}"]
@pytest.fixture(autouse=True)
def clear_cached_classproperty():
cached_classproperty.cache.clear()

View file

@ -38,13 +38,15 @@ def env() -> Generator[FtInTitlePluginFunctional, None, None]:
def set_config(
env: FtInTitlePluginFunctional, cfg: Optional[Dict[str, Union[str, bool]]]
env: FtInTitlePluginFunctional,
cfg: Optional[Dict[str, Union[str, bool, list[str]]]],
) -> None:
cfg = {} if cfg is None else cfg
defaults = {
"drop": False,
"auto": True,
"keep_in_artist": False,
"custom_words": [],
}
env.config["ftintitle"].set(defaults)
env.config["ftintitle"].set(cfg)
@ -170,11 +172,44 @@ def add_item(
("Alice ft Bob", "Song 1"),
id="keep-in-artist-drop-from-title",
),
# ---- custom_words variants ----
pytest.param(
{"format": "featuring {}", "custom_words": ["med"]},
("ftintitle",),
("Alice med Bob", "Song 1", "Alice"),
("Alice", "Song 1 featuring Bob"),
id="custom-feat-words",
),
pytest.param(
{
"format": "featuring {}",
"keep_in_artist": True,
"custom_words": ["med"],
},
("ftintitle",),
("Alice med Bob", "Song 1", "Alice"),
("Alice med Bob", "Song 1 featuring Bob"),
id="custom-feat-words-keep-in-artists",
),
pytest.param(
{
"format": "featuring {}",
"keep_in_artist": True,
"custom_words": ["med"],
},
(
"ftintitle",
"-d",
),
("Alice med Bob", "Song 1", "Alice"),
("Alice med Bob", "Song 1"),
id="custom-feat-words-keep-in-artists-drop-from-title",
),
],
)
def test_ftintitle_functional(
env: FtInTitlePluginFunctional,
cfg: Optional[Dict[str, Union[str, bool]]],
cfg: Optional[Dict[str, Union[str, bool, list[str]]]],
cmd_args: Tuple[str, ...],
given: Tuple[str, str, Optional[str]],
expected: Tuple[str, str],
@ -256,3 +291,35 @@ def test_split_on_feat(
)
def test_contains_feat(given: str, expected: bool) -> None:
assert ftintitle.contains_feat(given) is expected
@pytest.mark.parametrize(
"given,custom_words,expected",
[
("Alice ft. Bob", [], True),
("Alice feat. Bob", [], True),
("Alice feat Bob", [], True),
("Alice featuring Bob", [], True),
("Alice (ft. Bob)", [], True),
("Alice (feat. Bob)", [], True),
("Alice [ft. Bob]", [], True),
("Alice [feat. Bob]", [], True),
("Alice defeat Bob", [], False),
("Aliceft.Bob", [], False),
("Alice (defeat Bob)", [], False),
("Live and Let Go", [], False),
("Come With Me", [], False),
("Alice x Bob", ["x"], True),
("Alice x Bob", ["X"], True),
("Alice och Xavier", ["x"], False),
("Alice ft. Xavier", ["x"], True),
("Alice med Carol", ["med"], True),
("Alice med Carol", [], False),
],
)
def test_custom_words(
given: str, custom_words: Optional[list[str]], expected: bool
) -> None:
if custom_words is None:
custom_words = []
assert ftintitle.contains_feat(given, custom_words) is expected

View file

@ -19,11 +19,13 @@ from unittest.mock import Mock, patch
import pytest
from beets.test import _common
from beets.test.helper import BeetsTestCase
from beets.test.helper import PluginTestCase
from beetsplug import lastgenre
class LastGenrePluginTest(BeetsTestCase):
class LastGenrePluginTest(PluginTestCase):
plugin = "lastgenre"
def setUp(self):
super().setUp()
self.plugin = lastgenre.LastGenrePlugin()
@ -131,6 +133,11 @@ class LastGenrePluginTest(BeetsTestCase):
"math rock",
]
@patch("beets.ui.should_write", Mock(return_value=True))
@patch(
"beetsplug.lastgenre.LastGenrePlugin._get_genre",
Mock(return_value=("Mock Genre", "mock stage")),
)
def test_pretend_option_skips_library_updates(self):
item = self.create_item(
album="Pretend Album",
@ -141,32 +148,17 @@ class LastGenrePluginTest(BeetsTestCase):
)
album = self.lib.add_album([item])
command = self.plugin.commands()[0]
opts, args = command.parser.parse_args(["--pretend"])
with patch.object(lastgenre.ui, "should_write", return_value=True):
with patch.object(
self.plugin,
"_get_genre",
return_value=("Mock Genre", "mock stage"),
) as mock_get_genre:
with patch.object(self.plugin._log, "info") as log_info:
# Mock try_write to verify it's never called in pretend mode
with patch.object(item, "try_write") as mock_try_write:
command.func(self.lib, opts, args)
mock_get_genre.assert_called_once()
assert any(
call.args[1] == "Pretend: " for call in log_info.call_args_list
)
def unexpected_store(*_, **__):
raise AssertionError("Unexpected store call")
# Verify that try_write was never called (file operations skipped)
mock_try_write.assert_not_called()
with patch("beetsplug.lastgenre.Item.store", unexpected_store):
output = self.run_with_output("lastgenre", "--pretend")
stored_album = self.lib.get_album(album.id)
assert stored_album.genre == "Original Genre"
assert stored_album.items()[0].genre == "Original Genre"
assert "Mock Genre" in output
album.load()
assert album.genre == "Original Genre"
assert album.items()[0].genre == "Original Genre"
def test_no_duplicate(self):
"""Remove duplicated genres."""

View file

@ -105,6 +105,19 @@ class PlayPluginTest(CleanupModulesMixin, PluginTestCase):
open_mock.assert_called_once_with([self.item.path], "echo")
def test_pls_marker(self, open_mock):
self.config["play"]["command"] = (
"echo --some params --playlist=$playlist --some-more params"
)
self.run_command("play", "nice")
open_mock.assert_called_once
commandstr = open_mock.call_args_list[0][0][1]
assert commandstr.startswith("echo --some params --playlist=")
assert commandstr.endswith(" --some-more params")
def test_not_found(self, open_mock):
self.run_command("play", "not found")

View file

@ -249,6 +249,54 @@ class ZeroPluginTest(PluginTestCase):
assert "id" not in z.fields_to_progs
def test_omit_single_disc_with_tags_single(self):
item = self.add_item_fixture(
disctotal=1, disc=1, comments="test comment"
)
item.write()
with self.configure_plugin(
{"omit_single_disc": True, "fields": ["comments"]}
):
item.write()
mf = MediaFile(syspath(item.path))
assert mf.comments is None
assert mf.disc == 0
def test_omit_single_disc_with_tags_multi(self):
item = self.add_item_fixture(
disctotal=4, disc=1, comments="test comment"
)
item.write()
with self.configure_plugin(
{"omit_single_disc": True, "fields": ["comments"]}
):
item.write()
mf = MediaFile(syspath(item.path))
assert mf.comments is None
assert mf.disc == 1
def test_omit_single_disc_only_change_single(self):
item = self.add_item_fixture(disctotal=1, disc=1)
item.write()
with self.configure_plugin({"omit_single_disc": True}):
item.write()
mf = MediaFile(syspath(item.path))
assert mf.disc == 0
def test_omit_single_disc_only_change_multi(self):
item = self.add_item_fixture(disctotal=4, disc=1)
item.write()
with self.configure_plugin({"omit_single_disc": True}):
item.write()
mf = MediaFile(syspath(item.path))
assert mf.disc == 1
def test_empty_query_n_response_no_changes(self):
item = self.add_item_fixture(
year=2016, day=13, month=3, comments="test comment"

View file

@ -523,3 +523,23 @@ class TestImportPlugin(PluginMixin):
assert "PluginImportError" not in caplog.text, (
f"Plugin '{plugin_name}' has issues during import."
)
class TestDeprecationCopy:
# TODO: remove this test in Beets 3.0.0
def test_legacy_metadata_plugin_deprecation(self):
"""Test that a MetadataSourcePlugin with 'legacy' data_source
raises a deprecation warning and all function and properties are
copied from the base class.
"""
with pytest.warns(DeprecationWarning, match="LegacyMetadataPlugin"):
class LegacyMetadataPlugin(plugins.BeetsPlugin):
data_source = "legacy"
# Assert all methods are present
assert hasattr(LegacyMetadataPlugin, "albums_for_ids")
assert hasattr(LegacyMetadataPlugin, "tracks_for_ids")
assert hasattr(LegacyMetadataPlugin, "data_source_mismatch_penalty")
assert hasattr(LegacyMetadataPlugin, "_extract_id")
assert hasattr(LegacyMetadataPlugin, "get_artist")