mirror of
https://github.com/beetbox/beets.git
synced 2026-01-14 12:12:23 +01:00
merge with master
This commit is contained in:
commit
57641ad9d2
46 changed files with 1701 additions and 710 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -94,6 +94,3 @@ ENV/
|
|||
|
||||
# pyright
|
||||
pyrightconfig.json
|
||||
|
||||
# Versioning
|
||||
beets/_version.py
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -51,15 +51,16 @@ SINGLE_ARTIST_THRESH = 0.25
|
|||
# def extend_reimport_fresh_fields_item():
|
||||
# importer.REIMPORT_FRESH_FIELDS_ITEM.extend(['tidal_track_popularity']
|
||||
# )
|
||||
REIMPORT_FRESH_FIELDS_ALBUM = [
|
||||
REIMPORT_FRESH_FIELDS_ITEM = [
|
||||
"data_source",
|
||||
"bandcamp_album_id",
|
||||
"spotify_album_id",
|
||||
"deezer_album_id",
|
||||
"beatport_album_id",
|
||||
"tidal_album_id",
|
||||
"data_url",
|
||||
]
|
||||
REIMPORT_FRESH_FIELDS_ITEM = list(REIMPORT_FRESH_FIELDS_ALBUM)
|
||||
REIMPORT_FRESH_FIELDS_ALBUM = [*REIMPORT_FRESH_FIELDS_ITEM, "media"]
|
||||
|
||||
# Global logger.
|
||||
log = logging.getLogger("beets")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -836,9 +836,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)
|
||||
|
||||
|
|
|
|||
|
|
@ -328,7 +328,6 @@ class BeatportPlugin(MetadataSourcePlugin):
|
|||
"apikey": "57713c3906af6f5def151b33601389176b37b429",
|
||||
"apisecret": "b3fe08c93c80aefd749fe871a16cd2bb32e2b954",
|
||||
"tokenfile": "beatport_token.json",
|
||||
"source_weight": 0.5,
|
||||
}
|
||||
)
|
||||
self.config["apikey"].redact = True
|
||||
|
|
|
|||
|
|
@ -27,13 +27,13 @@ import time
|
|||
import traceback
|
||||
from functools import cache
|
||||
from string import ascii_lowercase
|
||||
from typing import TYPE_CHECKING, Sequence
|
||||
from typing import TYPE_CHECKING, Sequence, cast
|
||||
|
||||
import confuse
|
||||
from discogs_client import Client, Master, Release
|
||||
from discogs_client.exceptions import DiscogsAPIError
|
||||
from requests.exceptions import ConnectionError
|
||||
from typing_extensions import TypedDict
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
import beets
|
||||
import beets.ui
|
||||
|
|
@ -85,6 +85,42 @@ class ReleaseFormat(TypedDict):
|
|||
descriptions: list[str] | None
|
||||
|
||||
|
||||
class Artist(TypedDict):
|
||||
name: str
|
||||
anv: str
|
||||
join: str
|
||||
role: str
|
||||
tracks: str
|
||||
id: str
|
||||
resource_url: str
|
||||
|
||||
|
||||
class Track(TypedDict):
|
||||
position: str
|
||||
type_: str
|
||||
title: str
|
||||
duration: str
|
||||
artists: list[Artist]
|
||||
extraartists: NotRequired[list[Artist]]
|
||||
|
||||
|
||||
class TrackWithSubtracks(Track):
|
||||
sub_tracks: list[TrackWithSubtracks]
|
||||
|
||||
|
||||
class IntermediateTrackInfo(TrackInfo):
|
||||
"""Allows work with string mediums from
|
||||
get_track_info"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
medium_str: str | None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self.medium_str = medium_str
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
||||
class DiscogsPlugin(MetadataSourcePlugin):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
|
@ -93,12 +129,17 @@ 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,
|
||||
"anv": {
|
||||
"artist_credit": True,
|
||||
"artist": False,
|
||||
"album_artist": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
self.config["apikey"].redact = True
|
||||
|
|
@ -106,7 +147,7 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
self.config["user_token"].redact = True
|
||||
self.setup()
|
||||
|
||||
def setup(self, session=None):
|
||||
def setup(self, session=None) -> None:
|
||||
"""Create the `discogs_client` field. Authenticate if necessary."""
|
||||
c_key = self.config["apikey"].as_str()
|
||||
c_secret = self.config["apisecret"].as_str()
|
||||
|
|
@ -132,16 +173,16 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
|
||||
self.discogs_client = Client(USER_AGENT, c_key, c_secret, token, secret)
|
||||
|
||||
def reset_auth(self):
|
||||
def reset_auth(self) -> None:
|
||||
"""Delete token file & redo the auth steps."""
|
||||
os.remove(self._tokenfile())
|
||||
self.setup()
|
||||
|
||||
def _tokenfile(self):
|
||||
def _tokenfile(self) -> str:
|
||||
"""Get the path to the JSON file for storing the OAuth token."""
|
||||
return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True))
|
||||
|
||||
def authenticate(self, c_key, c_secret):
|
||||
def authenticate(self, c_key: str, c_secret: str) -> tuple[str, str]:
|
||||
# Get the link for the OAuth page.
|
||||
auth_client = Client(USER_AGENT, c_key, c_secret)
|
||||
try:
|
||||
|
|
@ -302,7 +343,26 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
|
||||
return media, albumtype
|
||||
|
||||
def get_album_info(self, result):
|
||||
def get_artist_with_anv(
|
||||
self, artists: list[Artist], use_anv: bool = False
|
||||
) -> tuple[str, str | None]:
|
||||
"""Iterates through a discogs result, fetching data
|
||||
if the artist anv is to be used, maps that to the name.
|
||||
Calls the parent class get_artist method."""
|
||||
artist_list: list[dict[str | int, str]] = []
|
||||
for artist_data in artists:
|
||||
a: dict[str | int, str] = {
|
||||
"name": artist_data["name"],
|
||||
"id": artist_data["id"],
|
||||
"join": artist_data.get("join", ""),
|
||||
}
|
||||
if use_anv and (anv := artist_data.get("anv", "")):
|
||||
a["name"] = anv
|
||||
artist_list.append(a)
|
||||
artist, artist_id = self.get_artist(artist_list, join_key="join")
|
||||
return self.strip_disambiguation(artist), artist_id
|
||||
|
||||
def get_album_info(self, result: Release) -> AlbumInfo | None:
|
||||
"""Returns an AlbumInfo object for a discogs Release object."""
|
||||
# Explicitly reload the `Release` fields, as they might not be yet
|
||||
# present if the result is from a `discogs_client.search()`.
|
||||
|
|
@ -330,16 +390,29 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
self._log.warning("Release does not contain the required fields")
|
||||
return None
|
||||
|
||||
artist, artist_id = self.get_artist(
|
||||
[a.data for a in result.artists], join_key="join"
|
||||
artist_data = [a.data for a in result.artists]
|
||||
album_artist, album_artist_id = self.get_artist_with_anv(artist_data)
|
||||
album_artist_anv, _ = self.get_artist_with_anv(
|
||||
artist_data, use_anv=True
|
||||
)
|
||||
artist_credit = album_artist_anv
|
||||
|
||||
album = re.sub(r" +", " ", result.title)
|
||||
album_id = result.data["id"]
|
||||
# Use `.data` to access the tracklist directly instead of the
|
||||
# convenient `.tracklist` property, which will strip out useful artist
|
||||
# information and leave us with skeleton `Artist` objects that will
|
||||
# each make an API call just to get the same data back.
|
||||
tracks = self.get_tracks(result.data["tracklist"], artist, artist_id)
|
||||
tracks = self.get_tracks(
|
||||
result.data["tracklist"],
|
||||
(album_artist, album_artist_anv, album_artist_id),
|
||||
)
|
||||
|
||||
# Assign ANV to the proper fields for tagging
|
||||
if not self.config["anv"]["artist_credit"]:
|
||||
artist_credit = album_artist
|
||||
if self.config["anv"]["album_artist"]:
|
||||
album_artist = album_artist_anv
|
||||
|
||||
# Extract information for the optional AlbumInfo fields, if possible.
|
||||
va = result.data["artists"][0].get("name", "").lower() == "various"
|
||||
|
|
@ -376,9 +449,9 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
# Additional cleanups
|
||||
# (various artists name, catalog number, media, disambiguation).
|
||||
if va:
|
||||
artist = config["va_name"].as_str()
|
||||
else:
|
||||
artist = self.strip_disambiguation(artist)
|
||||
va_name = config["va_name"].as_str()
|
||||
album_artist = va_name
|
||||
artist_credit = va_name
|
||||
if catalogno == "none":
|
||||
catalogno = None
|
||||
# Explicitly set the `media` for the tracks, since it is expected by
|
||||
|
|
@ -401,8 +474,9 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
return AlbumInfo(
|
||||
album=album,
|
||||
album_id=album_id,
|
||||
artist=artist,
|
||||
artist_id=artist_id,
|
||||
artist=album_artist,
|
||||
artist_credit=artist_credit,
|
||||
artist_id=album_artist_id,
|
||||
tracks=tracks,
|
||||
albumtype=albumtype,
|
||||
va=va,
|
||||
|
|
@ -420,11 +494,11 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
data_url=data_url,
|
||||
discogs_albumid=discogs_albumid,
|
||||
discogs_labelid=labelid,
|
||||
discogs_artistid=artist_id,
|
||||
discogs_artistid=album_artist_id,
|
||||
cover_art_url=cover_art_url,
|
||||
)
|
||||
|
||||
def select_cover_art(self, result):
|
||||
def select_cover_art(self, result: Release) -> str | None:
|
||||
"""Returns the best candidate image, if any, from a Discogs `Release` object."""
|
||||
if result.data.get("images") and len(result.data.get("images")) > 0:
|
||||
# The first image in this list appears to be the one displayed first
|
||||
|
|
@ -434,7 +508,7 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
|
||||
return None
|
||||
|
||||
def format(self, classification):
|
||||
def format(self, classification: Iterable[str]) -> str | None:
|
||||
if classification:
|
||||
return (
|
||||
self.config["separator"].as_str().join(sorted(classification))
|
||||
|
|
@ -442,22 +516,17 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
else:
|
||||
return None
|
||||
|
||||
def get_tracks(self, tracklist, album_artist, album_artist_id):
|
||||
"""Returns a list of TrackInfo objects for a discogs tracklist."""
|
||||
try:
|
||||
clean_tracklist = self.coalesce_tracks(tracklist)
|
||||
except Exception as exc:
|
||||
# FIXME: this is an extra precaution for making sure there are no
|
||||
# side effects after #2222. It should be removed after further
|
||||
# testing.
|
||||
self._log.debug("{}", traceback.format_exc())
|
||||
self._log.error("uncaught exception in coalesce_tracks: {}", exc)
|
||||
clean_tracklist = tracklist
|
||||
tracks = []
|
||||
def _process_clean_tracklist(
|
||||
self,
|
||||
clean_tracklist: list[Track],
|
||||
album_artist_data: tuple[str, str, str | None],
|
||||
) -> tuple[list[TrackInfo], dict[int, str], int, list[str], list[str]]:
|
||||
# Distinct works and intra-work divisions, as defined by index tracks.
|
||||
tracks: list[TrackInfo] = []
|
||||
index_tracks = {}
|
||||
index = 0
|
||||
# Distinct works and intra-work divisions, as defined by index tracks.
|
||||
divisions, next_divisions = [], []
|
||||
divisions: list[str] = []
|
||||
next_divisions: list[str] = []
|
||||
for track in clean_tracklist:
|
||||
# Only real tracks have `position`. Otherwise, it's an index track.
|
||||
if track["position"]:
|
||||
|
|
@ -468,7 +537,7 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
divisions += next_divisions
|
||||
del next_divisions[:]
|
||||
track_info = self.get_track_info(
|
||||
track, index, divisions, album_artist, album_artist_id
|
||||
track, index, divisions, album_artist_data
|
||||
)
|
||||
track_info.track_alt = track["position"]
|
||||
tracks.append(track_info)
|
||||
|
|
@ -481,7 +550,29 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
except IndexError:
|
||||
pass
|
||||
index_tracks[index + 1] = track["title"]
|
||||
return tracks, index_tracks, index, divisions, next_divisions
|
||||
|
||||
def get_tracks(
|
||||
self,
|
||||
tracklist: list[Track],
|
||||
album_artist_data: tuple[str, str, str | None],
|
||||
) -> list[TrackInfo]:
|
||||
"""Returns a list of TrackInfo objects for a discogs tracklist."""
|
||||
try:
|
||||
clean_tracklist: list[Track] = self.coalesce_tracks(
|
||||
cast(list[TrackWithSubtracks], tracklist)
|
||||
)
|
||||
except Exception as exc:
|
||||
# FIXME: this is an extra precaution for making sure there are no
|
||||
# side effects after #2222. It should be removed after further
|
||||
# testing.
|
||||
self._log.debug("{}", traceback.format_exc())
|
||||
self._log.error("uncaught exception in coalesce_tracks: {}", exc)
|
||||
clean_tracklist = tracklist
|
||||
processed = self._process_clean_tracklist(
|
||||
clean_tracklist, album_artist_data
|
||||
)
|
||||
tracks, index_tracks, index, divisions, next_divisions = processed
|
||||
# Fix up medium and medium_index for each track. Discogs position is
|
||||
# unreliable, but tracks are in order.
|
||||
medium = None
|
||||
|
|
@ -490,8 +581,8 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
|
||||
# If a medium has two sides (ie. vinyl or cassette), each pair of
|
||||
# consecutive sides should belong to the same medium.
|
||||
if all([track.medium is not None for track in tracks]):
|
||||
m = sorted({track.medium.lower() for track in tracks})
|
||||
if all([track.medium_str is not None for track in tracks]):
|
||||
m = sorted({track.medium_str.lower() for track in tracks})
|
||||
# If all track.medium are single consecutive letters, assume it is
|
||||
# a 2-sided medium.
|
||||
if "".join(m) in ascii_lowercase:
|
||||
|
|
@ -505,17 +596,17 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
# side_count is the number of mediums or medium sides (in the case
|
||||
# of two-sided mediums) that were seen before.
|
||||
medium_is_index = (
|
||||
track.medium
|
||||
track.medium_str
|
||||
and not track.medium_index
|
||||
and (
|
||||
len(track.medium) != 1
|
||||
len(track.medium_str) != 1
|
||||
or
|
||||
# Not within standard incremental medium values (A, B, C, ...).
|
||||
ord(track.medium) - 64 != side_count + 1
|
||||
ord(track.medium_str) - 64 != side_count + 1
|
||||
)
|
||||
)
|
||||
|
||||
if not medium_is_index and medium != track.medium:
|
||||
if not medium_is_index and medium != track.medium_str:
|
||||
side_count += 1
|
||||
if sides_per_medium == 2:
|
||||
if side_count % sides_per_medium:
|
||||
|
|
@ -526,7 +617,7 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
# Medium changed. Reset index_count.
|
||||
medium_count += 1
|
||||
index_count = 0
|
||||
medium = track.medium
|
||||
medium = track.medium_str
|
||||
|
||||
index_count += 1
|
||||
medium_count = 1 if medium_count == 0 else medium_count
|
||||
|
|
@ -542,15 +633,20 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
disctitle = None
|
||||
track.disctitle = disctitle
|
||||
|
||||
return tracks
|
||||
return cast(list[TrackInfo], tracks)
|
||||
|
||||
def coalesce_tracks(self, raw_tracklist):
|
||||
def coalesce_tracks(
|
||||
self, raw_tracklist: list[TrackWithSubtracks]
|
||||
) -> list[Track]:
|
||||
"""Pre-process a tracklist, merging subtracks into a single track. The
|
||||
title for the merged track is the one from the previous index track,
|
||||
if present; otherwise it is a combination of the subtracks titles.
|
||||
"""
|
||||
|
||||
def add_merged_subtracks(tracklist, subtracks):
|
||||
def add_merged_subtracks(
|
||||
tracklist: list[TrackWithSubtracks],
|
||||
subtracks: list[TrackWithSubtracks],
|
||||
) -> None:
|
||||
"""Modify `tracklist` in place, merging a list of `subtracks` into
|
||||
a single track into `tracklist`."""
|
||||
# Calculate position based on first subtrack, without subindex.
|
||||
|
|
@ -590,8 +686,8 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
tracklist.append(track)
|
||||
|
||||
# Pre-process the tracklist, trying to identify subtracks.
|
||||
subtracks = []
|
||||
tracklist = []
|
||||
subtracks: list[TrackWithSubtracks] = []
|
||||
tracklist: list[TrackWithSubtracks] = []
|
||||
prev_subindex = ""
|
||||
for track in raw_tracklist:
|
||||
# Regular subtrack (track with subindex).
|
||||
|
|
@ -626,7 +722,7 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
if subtracks:
|
||||
add_merged_subtracks(tracklist, subtracks)
|
||||
|
||||
return tracklist
|
||||
return cast(list[Track], tracklist)
|
||||
|
||||
def strip_disambiguation(self, text: str) -> str:
|
||||
"""Removes discogs specific disambiguations from a string.
|
||||
|
|
@ -637,9 +733,21 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
return DISAMBIGUATION_RE.sub("", text)
|
||||
|
||||
def get_track_info(
|
||||
self, track, index, divisions, album_artist, album_artist_id
|
||||
):
|
||||
self,
|
||||
track: Track,
|
||||
index: int,
|
||||
divisions: list[str],
|
||||
album_artist_data: tuple[str, str, str | None],
|
||||
) -> IntermediateTrackInfo:
|
||||
"""Returns a TrackInfo object for a discogs track."""
|
||||
|
||||
artist, artist_anv, artist_id = album_artist_data
|
||||
artist_credit = artist_anv
|
||||
if not self.config["anv"]["artist_credit"]:
|
||||
artist_credit = artist
|
||||
if self.config["anv"]["artist"]:
|
||||
artist = artist_anv
|
||||
|
||||
title = track["title"]
|
||||
if self.config["index_tracks"]:
|
||||
prefix = ", ".join(divisions)
|
||||
|
|
@ -647,32 +755,44 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
title = f"{prefix}: {title}"
|
||||
track_id = None
|
||||
medium, medium_index, _ = self.get_track_index(track["position"])
|
||||
artist, artist_id = self.get_artist(
|
||||
track.get("artists", []), join_key="join"
|
||||
)
|
||||
# If no artist and artist is returned, set to match album artist
|
||||
if not artist:
|
||||
artist = album_artist
|
||||
artist_id = album_artist_id
|
||||
|
||||
# If artists are found on the track, we will use those instead
|
||||
if artists := track.get("artists", []):
|
||||
artist, artist_id = self.get_artist_with_anv(
|
||||
artists, self.config["anv"]["artist"]
|
||||
)
|
||||
artist_credit, _ = self.get_artist_with_anv(
|
||||
artists, self.config["anv"]["artist_credit"]
|
||||
)
|
||||
length = self.get_track_length(track["duration"])
|
||||
|
||||
# Add featured artists
|
||||
extraartists = track.get("extraartists", [])
|
||||
featured = [
|
||||
artist["name"]
|
||||
for artist in extraartists
|
||||
if "Featuring" in artist["role"]
|
||||
]
|
||||
if featured:
|
||||
artist = f"{artist} feat. {', '.join(featured)}"
|
||||
artist = self.strip_disambiguation(artist)
|
||||
return TrackInfo(
|
||||
if extraartists := track.get("extraartists", []):
|
||||
featured_list = [
|
||||
artist
|
||||
for artist in extraartists
|
||||
if "Featuring" in artist["role"]
|
||||
]
|
||||
featured, _ = self.get_artist_with_anv(
|
||||
featured_list, self.config["anv"]["artist"]
|
||||
)
|
||||
featured_credit, _ = self.get_artist_with_anv(
|
||||
featured_list, self.config["anv"]["artist_credit"]
|
||||
)
|
||||
if featured:
|
||||
artist += f" {self.config['featured_string']} {featured}"
|
||||
artist_credit += (
|
||||
f" {self.config['featured_string']} {featured_credit}"
|
||||
)
|
||||
return IntermediateTrackInfo(
|
||||
title=title,
|
||||
track_id=track_id,
|
||||
artist_credit=artist_credit,
|
||||
artist=artist,
|
||||
artist_id=artist_id,
|
||||
length=length,
|
||||
index=index,
|
||||
medium=medium,
|
||||
medium_str=medium,
|
||||
medium_index=medium_index,
|
||||
)
|
||||
|
||||
|
|
@ -693,7 +813,7 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
|||
|
||||
return medium or None, index or None, subindex or None
|
||||
|
||||
def get_track_length(self, duration):
|
||||
def get_track_length(self, duration: str) -> int | None:
|
||||
"""Returns the track length in seconds for a discogs duration."""
|
||||
try:
|
||||
length = time.strptime(duration, "%M:%S")
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@
|
|||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
"""If the title is empty, try to extract track and title from the
|
||||
filename.
|
||||
"""If the title is empty, try to extract it from the filename
|
||||
(possibly also extract track and artist)
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -25,12 +25,12 @@ from beets.util import displayable_path
|
|||
# Filename field extraction patterns.
|
||||
PATTERNS = [
|
||||
# Useful patterns.
|
||||
r"^(?P<artist>.+)[\-_](?P<title>.+)[\-_](?P<tag>.*)$",
|
||||
r"^(?P<track>\d+)[\s.\-_]+(?P<artist>.+)[\-_](?P<title>.+)[\-_](?P<tag>.*)$",
|
||||
r"^(?P<artist>.+)[\-_](?P<title>.+)$",
|
||||
r"^(?P<track>\d+)[\s.\-_]+(?P<artist>.+)[\-_](?P<title>.+)$",
|
||||
r"^(?P<track>\d+)[\s.\-_]+(?P<title>.+)$",
|
||||
r"^(?P<track>\d+)\s+(?P<title>.+)$",
|
||||
(
|
||||
r"^(?P<track>\d+)\.?\s*-\s*(?P<artist>.+?)\s*-\s*(?P<title>.+?)"
|
||||
r"(\s*-\s*(?P<tag>.*))?$"
|
||||
),
|
||||
r"^(?P<artist>.+?)\s*-\s*(?P<title>.+?)(\s*-\s*(?P<tag>.*))?$",
|
||||
r"^(?P<track>\d+)\.?[\s_-]+(?P<title>.+)$",
|
||||
r"^(?P<title>.+) by (?P<artist>.+)$",
|
||||
r"^(?P<track>\d+).*$",
|
||||
r"^(?P<title>.+)$",
|
||||
|
|
@ -98,6 +98,7 @@ def apply_matches(d, log):
|
|||
# Given both an "artist" and "title" field, assume that one is
|
||||
# *actually* the artist, which must be uniform, and use the other
|
||||
# for the title. This, of course, won't work for VA albums.
|
||||
# Only check for "artist": patterns containing it, also contain "title"
|
||||
if "artist" in keys:
|
||||
if equal_fields(d, "artist"):
|
||||
artist = some_map["artist"]
|
||||
|
|
@ -113,15 +114,16 @@ def apply_matches(d, log):
|
|||
if not item.artist:
|
||||
item.artist = artist
|
||||
log.info("Artist replaced with: {.artist}", item)
|
||||
|
||||
# No artist field: remaining field is the title.
|
||||
else:
|
||||
# otherwise, if the pattern contains "title", use that for title_field
|
||||
elif "title" in keys:
|
||||
title_field = "title"
|
||||
else:
|
||||
title_field = None
|
||||
|
||||
# Apply the title and track.
|
||||
# Apply the title and track, if any.
|
||||
for item in d:
|
||||
if bad_title(item.title):
|
||||
item.title = str(d[item].get(title_field, ""))
|
||||
if title_field and bad_title(item.title):
|
||||
item.title = str(d[item][title_field])
|
||||
log.info("Title replaced with: {.title}", item)
|
||||
|
||||
if "track" in d[item] and item.track == 0:
|
||||
|
|
@ -160,6 +162,7 @@ class FromFilenamePlugin(plugins.BeetsPlugin):
|
|||
|
||||
# Look for useful information in the filenames.
|
||||
for pattern in PATTERNS:
|
||||
self._log.debug(f"Trying pattern: {pattern}")
|
||||
d = all_matches(names, pattern)
|
||||
if d:
|
||||
apply_matches(d, self._log)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,68 @@ Unreleased
|
|||
|
||||
New features:
|
||||
|
||||
- :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle.
|
||||
|
||||
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.
|
||||
|
||||
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` Added support for featured artists.
|
||||
- :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` Support for `artist_credit` in Discogs tags.
|
||||
:bug:`3354`
|
||||
- :doc:`plugins/discogs` Support for name variations and config options to
|
||||
specify where the variations are written. :bug:`3354`
|
||||
|
||||
Bug fixes:
|
||||
|
||||
- :doc:`plugins/musicbrainz` Refresh flexible MusicBrainz metadata on reimport
|
||||
so format changes are applied. :bug:`6036`
|
||||
- :doc:`plugins/spotify` Ensure ``spotifysync`` keeps popularity, ISRC, and
|
||||
related fields current even when audio features requests fail. :bug:`6061`
|
||||
- :doc:`plugins/spotify` Fixed an issue where track matching and lookups could
|
||||
|
|
@ -28,16 +80,18 @@ Bug fixes:
|
|||
- :doc:`plugins/spotify` Removed old and undocumented config options
|
||||
`artist_field`, `album_field` and `track` that were causing issues with track
|
||||
matching. :bug:`5189`
|
||||
- :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from
|
||||
artists but not labels. :bug:`5366`
|
||||
- :doc:`plugins/discogs` Fixed issue with ignoring featured artists in the
|
||||
extraartists field.
|
||||
- :doc:`plugins/spotify` Fixed an issue where candidate lookup would not find
|
||||
matches due to query escaping (single vs double quotes).
|
||||
- :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`
|
||||
|
||||
For packagers:
|
||||
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.
|
||||
- Metadata source plugins: Fixed data source penalty calculation that was
|
||||
incorrectly applied during import matching. The ``source_weight``
|
||||
configuration option has been renamed to ``data_source_mismatch_penalty`` to
|
||||
better reflect its purpose. :bug:`6066`
|
||||
|
||||
Other changes:
|
||||
|
||||
|
|
@ -57,12 +111,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)
|
||||
--------------------------
|
||||
|
|
@ -143,8 +207,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
|
||||
|
|
@ -5027,7 +5091,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)
|
||||
|
|
|
|||
10
docs/conf.py
10
docs/conf.py
|
|
@ -13,8 +13,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 +23,16 @@ extensions = [
|
|||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.autosummary",
|
||||
"sphinx.ext.extlinks",
|
||||
"sphinx.ext.viewcode",
|
||||
"sphinx_design",
|
||||
"sphinx_copybutton",
|
||||
]
|
||||
|
||||
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 +82,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
|
||||
|
|
|
|||
|
|
@ -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,14 +27,17 @@ 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
|
||||
|
||||
deezer:
|
||||
data_source_mismatch_penalty: 0.5
|
||||
search_limit: 5
|
||||
search_query_ascii: no
|
||||
|
||||
- **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
|
||||
|
|
|
|||
|
|
@ -65,38 +65,45 @@ 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:
|
||||
Default
|
||||
~~~~~~~
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
discogs:
|
||||
index_tracks: yes
|
||||
data_source_mismatch_penalty: 0.5
|
||||
search_limit: 5
|
||||
apikey: REDACTED
|
||||
apisecret: REDACTED
|
||||
tokenfile: discogs_token.json
|
||||
user_token: REDACTED
|
||||
index_tracks: no
|
||||
append_style_genre: no
|
||||
separator: ', '
|
||||
strip_disambiguation: yes
|
||||
|
||||
beets will incorporate the names of the divisions containing each track into the
|
||||
imported track's title. Default: ``no``.
|
||||
- **index_tracks**: 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. Default: ``no``.
|
||||
|
||||
For example, importing `divisions album`_ would result in track names like:
|
||||
For example, importing `divisions album`_ would result in track names like:
|
||||
|
||||
.. code-block:: text
|
||||
.. 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
|
||||
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:
|
||||
whereas with ``index_tracks`` disabled you'd get:
|
||||
|
||||
.. code-block:: text
|
||||
.. code-block:: text
|
||||
|
||||
No.1: Sinfony
|
||||
No.22: Chorus- Behold The Lamb Of God
|
||||
Sinfonia
|
||||
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:
|
||||
This option is useful when importing classical music.
|
||||
|
||||
- **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.
|
||||
|
|
@ -106,12 +113,25 @@ Other configurations available under ``discogs:`` are:
|
|||
"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.
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
discogs:
|
||||
anv:
|
||||
artist_credit: True
|
||||
artist: False
|
||||
album_artist: False
|
||||
|
||||
.. _discogs guidelines: https://support.discogs.com/hc/en-us/articles/360005055373-Database-Guidelines-12-Tracklisting#Index_Tracks_And_Headings
|
||||
|
||||
|
|
|
|||
|
|
@ -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,68 @@ 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``.
|
||||
.. _data_source_mismatch_penalty:
|
||||
|
||||
For example, to equally consider matches from Discogs and MusicBrainz add the
|
||||
following to your configuration:
|
||||
- **data_source_mismatch_penalty**: 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. Default: ``0.5``.
|
||||
|
||||
.. code-block:: yaml
|
||||
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.
|
||||
|
||||
plugins: musicbrainz discogs
|
||||
**Example configurations:**
|
||||
|
||||
discogs:
|
||||
source_weight: 0.0
|
||||
.. 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
|
||||
|
||||
- **source_weight**
|
||||
|
||||
.. deprecated:: 2.5 Use `data_source_mismatch_penalty`_ instead.
|
||||
|
||||
- **search_limit**: Maximum number of search results to consider. Default:
|
||||
``5``.
|
||||
|
||||
.. toctree::
|
||||
:hidden:
|
||||
|
|
|
|||
|
|
@ -17,17 +17,21 @@ 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
|
||||
~~~~~~~
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
musicbrainz:
|
||||
data_source_mismatch_penalty: 0.5
|
||||
search_limit: 5
|
||||
host: musicbrainz.org
|
||||
https: no
|
||||
ratelimit: 1
|
||||
ratelimit_interval: 1.0
|
||||
search_limit: 5
|
||||
extra_tags: []
|
||||
genres: no
|
||||
external_ids:
|
||||
|
|
@ -74,7 +78,7 @@ limited_ to one request per second.
|
|||
enabled
|
||||
+++++++
|
||||
|
||||
.. deprecated:: 2.3 Add ``musicbrainz`` to the ``plugins`` list instead.
|
||||
.. deprecated:: 2.4 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
|
||||
|
|
|
|||
|
|
@ -65,11 +65,25 @@ 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
|
||||
~~~~~~~
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
spotify:
|
||||
data_source_mismatch_penalty: 0.5
|
||||
search_limit: 5
|
||||
mode: list
|
||||
region_filter:
|
||||
show_failures: no
|
||||
tiebreak: popularity
|
||||
regex: []
|
||||
search_query_ascii: no
|
||||
client_id: REDACTED
|
||||
client_secret: REDACTED
|
||||
tokenfile: spotify_token.json
|
||||
|
||||
- **mode**: One of the following:
|
||||
|
||||
|
|
@ -98,15 +112,13 @@ config.yaml under the ``spotify:`` section:
|
|||
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:
|
||||
|
||||
::
|
||||
|
||||
spotify:
|
||||
source_weight: 0.7
|
||||
data_source_mismatch_penalty: 0.7
|
||||
mode: open
|
||||
region_filter: US
|
||||
show_failures: on
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
::
|
||||
|
||||
|
|
@ -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):`+(?:([^`<]+)<)?/?([\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),
|
||||
]
|
||||
|
|
|
|||
47
poetry.lock
generated
47
poetry.lock
generated
|
|
@ -3218,6 +3218,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"
|
||||
|
|
@ -3622,7 +3665,7 @@ beatport = ["requests-oauthlib"]
|
|||
bpd = ["PyGObject"]
|
||||
chroma = ["pyacoustid"]
|
||||
discogs = ["python3-discogs-client"]
|
||||
docs = ["pydata-sphinx-theme", "sphinx"]
|
||||
docs = ["pydata-sphinx-theme", "sphinx", "sphinx-copybutton", "sphinx-design"]
|
||||
embedart = ["Pillow"]
|
||||
embyupdate = ["requests"]
|
||||
fetchart = ["Pillow", "beautifulsoup4", "langdetect", "requests"]
|
||||
|
|
@ -3645,4 +3688,4 @@ web = ["flask", "flask-cors"]
|
|||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = ">=3.9,<4"
|
||||
content-hash = "c5a6a4710beb5bf1e4bbd97f2a590ee020a567aea2341aad5f846eda13ebb94e"
|
||||
content-hash = "1db39186aca430ef6f1fd9e51b9dcc3ed91880a458bc21b22d950ed8589fdf5a"
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -80,6 +80,8 @@ titlecase = { version = ">=2.4.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 = "*"
|
||||
|
|
@ -130,7 +132,7 @@ 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 = ["pydata-sphinx-theme", "sphinx", "sphinx-lint", "sphinx-design", "sphinx-copybutton"]
|
||||
discogs = ["python3-discogs-client"]
|
||||
embedart = ["Pillow"] # ImageMagick
|
||||
embyupdate = ["requests"]
|
||||
|
|
@ -158,18 +160,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()
|
||||
|
|
|
|||
|
|
@ -453,6 +453,116 @@ class DGAlbumInfoTest(BeetsTestCase):
|
|||
config["discogs"]["strip_disambiguation"] = True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"track_artist_anv,track_artist",
|
||||
[(False, "ARTIST Feat. PERFORMER"), (True, "VARIATION Feat. VARIATION")],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"album_artist_anv,album_artist",
|
||||
[(False, "ARTIST & SOLOIST"), (True, "VARIATION & VARIATION")],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"artist_credit_anv,track_artist_credit,album_artist_credit",
|
||||
[
|
||||
(False, "ARTIST Feat. PERFORMER", "ARTIST & SOLOIST"),
|
||||
(True, "VARIATION Feat. VARIATION", "VARIATION & VARIATION"),
|
||||
],
|
||||
)
|
||||
@patch("beetsplug.discogs.DiscogsPlugin.setup", Mock())
|
||||
def test_anv(
|
||||
track_artist_anv,
|
||||
track_artist,
|
||||
album_artist_anv,
|
||||
album_artist,
|
||||
artist_credit_anv,
|
||||
track_artist_credit,
|
||||
album_artist_credit,
|
||||
):
|
||||
"""Test using artist name variations."""
|
||||
data = {
|
||||
"id": 123,
|
||||
"uri": "https://www.discogs.com/release/123456-something",
|
||||
"tracklist": [
|
||||
{
|
||||
"title": "track",
|
||||
"position": "A",
|
||||
"type_": "track",
|
||||
"duration": "5:44",
|
||||
"artists": [
|
||||
{
|
||||
"name": "ARTIST",
|
||||
"tracks": "",
|
||||
"anv": "VARIATION",
|
||||
"id": 11146,
|
||||
}
|
||||
],
|
||||
"extraartists": [
|
||||
{
|
||||
"name": "PERFORMER",
|
||||
"role": "Featuring",
|
||||
"anv": "VARIATION",
|
||||
"id": 787,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"artists": [
|
||||
{"name": "ARTIST (4)", "anv": "VARIATION", "id": 321, "join": "&"},
|
||||
{"name": "SOLOIST", "anv": "VARIATION", "id": 445, "join": ""},
|
||||
],
|
||||
"title": "title",
|
||||
}
|
||||
release = Bag(
|
||||
data=data,
|
||||
title=data["title"],
|
||||
artists=[Bag(data=d) for d in data["artists"]],
|
||||
)
|
||||
config["discogs"]["anv"]["album_artist"] = album_artist_anv
|
||||
config["discogs"]["anv"]["artist"] = track_artist_anv
|
||||
config["discogs"]["anv"]["artist_credit"] = artist_credit_anv
|
||||
r = DiscogsPlugin().get_album_info(release)
|
||||
assert r.artist == album_artist
|
||||
assert r.artist_credit == album_artist_credit
|
||||
assert r.tracks[0].artist == track_artist
|
||||
assert r.tracks[0].artist_credit == track_artist_credit
|
||||
|
||||
|
||||
@patch("beetsplug.discogs.DiscogsPlugin.setup", Mock())
|
||||
def test_anv_album_artist():
|
||||
"""Test using artist name variations when the album artist
|
||||
is the same as the track artist, but only the track artist
|
||||
should use the artist name variation."""
|
||||
data = {
|
||||
"id": 123,
|
||||
"uri": "https://www.discogs.com/release/123456-something",
|
||||
"tracklist": [
|
||||
{
|
||||
"title": "track",
|
||||
"position": "A",
|
||||
"type_": "track",
|
||||
"duration": "5:44",
|
||||
}
|
||||
],
|
||||
"artists": [
|
||||
{"name": "ARTIST (4)", "anv": "VARIATION", "id": 321},
|
||||
],
|
||||
"title": "title",
|
||||
}
|
||||
release = Bag(
|
||||
data=data,
|
||||
title=data["title"],
|
||||
artists=[Bag(data=d) for d in data["artists"]],
|
||||
)
|
||||
config["discogs"]["anv"]["album_artist"] = False
|
||||
config["discogs"]["anv"]["artist"] = True
|
||||
config["discogs"]["anv"]["artist_credit"] = False
|
||||
r = DiscogsPlugin().get_album_info(release)
|
||||
assert r.artist == "ARTIST"
|
||||
assert r.artist_credit == "ARTIST"
|
||||
assert r.tracks[0].artist == "VARIATION"
|
||||
assert r.tracks[0].artist_credit == "ARTIST"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"track, expected_artist",
|
||||
[
|
||||
|
|
@ -469,23 +579,27 @@ class DGAlbumInfoTest(BeetsTestCase):
|
|||
"extraartists": [
|
||||
{
|
||||
"name": "SOLOIST",
|
||||
"id": 3,
|
||||
"role": "Featuring",
|
||||
},
|
||||
{
|
||||
"name": "PERFORMER (1)",
|
||||
"id": 5,
|
||||
"role": "Other Role, Featuring",
|
||||
},
|
||||
{
|
||||
"name": "RANDOM",
|
||||
"id": 8,
|
||||
"role": "Written-By",
|
||||
},
|
||||
{
|
||||
"name": "MUSICIAN",
|
||||
"id": 10,
|
||||
"role": "Featuring [Uncredited]",
|
||||
},
|
||||
],
|
||||
},
|
||||
"NEW ARTIST, VOCALIST feat. SOLOIST, PERFORMER, MUSICIAN",
|
||||
"NEW ARTIST, VOCALIST Feat. SOLOIST, PERFORMER, MUSICIAN",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
|
@ -494,7 +608,9 @@ def test_parse_featured_artists(track, expected_artist):
|
|||
"""Tests the plugins ability to parse a featured artist.
|
||||
Initial check with one featured artist, two featured artists,
|
||||
and three. Ignores artists that are not listed as featured."""
|
||||
t = DiscogsPlugin().get_track_info(track, 1, 1, "ARTIST", 2)
|
||||
t = DiscogsPlugin().get_track_info(
|
||||
track, 1, 1, ("ARTIST", "ARTIST CREDIT", 2)
|
||||
)
|
||||
assert t.artist == expected_artist
|
||||
|
||||
|
||||
|
|
|
|||
99
test/plugins/test_fromfilename.py
Normal file
99
test/plugins/test_fromfilename.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# This file is part of beets.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
"""Tests for the fromfilename plugin."""
|
||||
|
||||
import pytest
|
||||
|
||||
from beetsplug import fromfilename
|
||||
|
||||
|
||||
class Session:
|
||||
pass
|
||||
|
||||
|
||||
class Item:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.track = 0
|
||||
self.artist = ""
|
||||
self.title = ""
|
||||
|
||||
|
||||
class Task:
|
||||
def __init__(self, items):
|
||||
self.items = items
|
||||
self.is_album = True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"song1, song2",
|
||||
[
|
||||
(
|
||||
(
|
||||
"/tmp/01 - The Artist - Song One.m4a",
|
||||
1,
|
||||
"The Artist",
|
||||
"Song One",
|
||||
),
|
||||
(
|
||||
"/tmp/02. - The Artist - Song Two.m4a",
|
||||
2,
|
||||
"The Artist",
|
||||
"Song Two",
|
||||
),
|
||||
),
|
||||
(
|
||||
("/tmp/01-The_Artist-Song_One.m4a", 1, "The_Artist", "Song_One"),
|
||||
("/tmp/02.-The_Artist-Song_Two.m4a", 2, "The_Artist", "Song_Two"),
|
||||
),
|
||||
(
|
||||
("/tmp/01 - Song_One.m4a", 1, "", "Song_One"),
|
||||
("/tmp/02. - Song_Two.m4a", 2, "", "Song_Two"),
|
||||
),
|
||||
(
|
||||
("/tmp/Song One by The Artist.m4a", 0, "The Artist", "Song One"),
|
||||
("/tmp/Song Two by The Artist.m4a", 0, "The Artist", "Song Two"),
|
||||
),
|
||||
(("/tmp/01.m4a", 1, "", "01"), ("/tmp/02.m4a", 2, "", "02")),
|
||||
(
|
||||
("/tmp/Song One.m4a", 0, "", "Song One"),
|
||||
("/tmp/Song Two.m4a", 0, "", "Song Two"),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_fromfilename(song1, song2):
|
||||
"""
|
||||
Each "song" is a tuple of path, expected track number, expected artist,
|
||||
expected title.
|
||||
|
||||
We use two songs for each test for two reasons:
|
||||
- The plugin needs more than one item to look for uniform strings in paths
|
||||
in order to guess if the string describes an artist or a title.
|
||||
- Sometimes we allow for an optional "." after the track number in paths.
|
||||
"""
|
||||
|
||||
session = Session()
|
||||
item1 = Item(song1[0])
|
||||
item2 = Item(song2[0])
|
||||
task = Task([item1, item2])
|
||||
|
||||
f = fromfilename.FromFilenamePlugin()
|
||||
f.filename_task(task, session)
|
||||
|
||||
assert task.items[0].track == song1[1]
|
||||
assert task.items[0].artist == song1[2]
|
||||
assert task.items[0].title == song1[3]
|
||||
assert task.items[1].track == song2[1]
|
||||
assert task.items[1].artist == song2[2]
|
||||
assert task.items[1].title == song2[3]
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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