mirror of
https://github.com/beetbox/beets.git
synced 2026-01-02 22:12:53 +01:00
Merge branch 'master' into feature/web-handle-nexttrack
This commit is contained in:
commit
99987b3f27
55 changed files with 1777 additions and 890 deletions
2
.github/workflows/changelog_reminder.yaml
vendored
2
.github/workflows/changelog_reminder.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
8
.github/workflows/ci.yaml
vendored
8
.github/workflows/ci.yaml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
4
.github/workflows/integration_test.yaml
vendored
4
.github/workflows/integration_test.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
18
.github/workflows/lint.yml
vendored
18
.github/workflows/lint.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
12
.github/workflows/make_release.yaml
vendored
12
.github/workflows/make_release.yaml
vendored
|
|
@ -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
4
.gitignore
vendored
|
|
@ -95,5 +95,5 @@ ENV/
|
|||
# pyright
|
||||
pyrightconfig.json
|
||||
|
||||
# Versioning
|
||||
beets/_version.py
|
||||
# Pyrefly
|
||||
pyrefly.toml
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
100
beets/plugins.py
100
beets/plugins.py
|
|
@ -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 metadata‐source 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 (
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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]):
|
||||
|
|
|
|||
|
|
@ -328,7 +328,6 @@ class BeatportPlugin(MetadataSourcePlugin):
|
|||
"apikey": "57713c3906af6f5def151b33601389176b37b429",
|
||||
"apisecret": "b3fe08c93c80aefd749fe871a16cd2bb32e2b954",
|
||||
"tokenfile": "beatport_token.json",
|
||||
"source_weight": 0.5,
|
||||
}
|
||||
)
|
||||
self.config["apikey"].redact = True
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
16
docs/conf.py
16
docs/conf.py
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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::
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
142
docs/extensions/conf.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -9,5 +9,6 @@ guide.
|
|||
:maxdepth: 1
|
||||
|
||||
main
|
||||
installation
|
||||
tagger
|
||||
advanced
|
||||
|
|
|
|||
179
docs/guides/installation.rst
Normal file
179
docs/guides/installation.rst
Normal 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
|
||||
|
|
@ -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>`_.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
--------
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
----------------
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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``.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
65
docs/plugins/shared_metadata_source_config.rst
Normal file
65
docs/plugins/shared_metadata_source_config.rst
Normal 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.
|
||||
|
|
@ -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
|
||||
----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
58
poetry.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue