diff --git a/.gitignore b/.gitignore index 102e1c3e4..90ef7387d 100644 --- a/.gitignore +++ b/.gitignore @@ -94,6 +94,3 @@ ENV/ # pyright pyrightconfig.json - -# Versioning -beets/_version.py diff --git a/beets/__init__.py b/beets/__init__.py index 5f4c6657d..d448d8c49 100644 --- a/beets/__init__.py +++ b/beets/__init__.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 " @@ -55,6 +54,3 @@ class IncludeLazyConfig(confuse.LazyConfig): config = IncludeLazyConfig("beets", __name__) - - -__all__ = ["__version__", "__version_tuple__", "config"] diff --git a/beets/_version.py b/beets/_version.py deleted file mode 100644 index 4dea56035..000000000 --- a/beets/_version.py +++ /dev/null @@ -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) diff --git a/beets/autotag/distance.py b/beets/autotag/distance.py index 727439ea3..37c6f84f4 100644 --- a/beets/autotag/distance.py +++ b/beets/autotag/distance.py @@ -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 diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 0a80f77f2..c0bab8056 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -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 diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index b4d566032..710f4da50 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -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") diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 56bf8124f..b865167e4 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -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. diff --git a/beets/plugins.py b/beets/plugins.py index c0dd12e5b..e10dcf80c 100644 --- a/beets/plugins.py +++ b/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 ( diff --git a/beets/test/helper.py b/beets/test/helper.py index 85adc0825..ea08ec840 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -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: diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index e0c1bb486..60e201448 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -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)) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index f895a60ee..0f2ef5b97 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -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) diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index fa681ce6a..c07cce72f 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -328,7 +328,6 @@ class BeatportPlugin(MetadataSourcePlugin): "apikey": "57713c3906af6f5def151b33601389176b37b429", "apisecret": "b3fe08c93c80aefd749fe871a16cd2bb32e2b954", "tokenfile": "beatport_token.json", - "source_weight": 0.5, } ) self.config["apikey"].redact = True diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 3dc962464..874eab6ec 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -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") diff --git a/beetsplug/fromfilename.py b/beetsplug/fromfilename.py index 7f9e9b92e..c3fb4bc6b 100644 --- a/beetsplug/fromfilename.py +++ b/beetsplug/fromfilename.py @@ -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.+)[\-_](?P.+)[\-_](?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) diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index e17d7bc1c..ef9b763cf 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -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 diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 1da5ecde4..902cef9ef 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -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. diff --git a/beetsplug/zero.py b/beetsplug/zero.py index bce3b1a72..ab1bfa5ca 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -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: diff --git a/docs/changelog.rst b/docs/changelog.rst index e74f0caa2..9a8fc539b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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) diff --git a/docs/conf.py b/docs/conf.py index 7465bdb27..c2cecc510 100644 --- a/docs/conf.py +++ b/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` diff --git a/docs/dev/plugins/autotagger.rst b/docs/dev/plugins/autotagger.rst index 1cae5295e..8b6df6fb5 100644 --- a/docs/dev/plugins/autotagger.rst +++ b/docs/dev/plugins/autotagger.rst @@ -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:: diff --git a/docs/dev/plugins/index.rst b/docs/dev/plugins/index.rst index d258e7df6..a8feb32d9 100644 --- a/docs/dev/plugins/index.rst +++ b/docs/dev/plugins/index.rst @@ -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 diff --git a/docs/faq.rst b/docs/faq.rst index 3e527e8bc..287dc88af 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -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. diff --git a/docs/guides/index.rst b/docs/guides/index.rst index 08685abba..0695e9ff8 100644 --- a/docs/guides/index.rst +++ b/docs/guides/index.rst @@ -9,5 +9,6 @@ guide. :maxdepth: 1 main + installation tagger advanced diff --git a/docs/guides/installation.rst b/docs/guides/installation.rst new file mode 100644 index 000000000..648a72d0b --- /dev/null +++ b/docs/guides/installation.rst @@ -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 diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 0b502bfb1..48b248927 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -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>`_. diff --git a/docs/guides/tagger.rst b/docs/guides/tagger.rst index c07d5df58..f43c1608c 100644 --- a/docs/guides/tagger.rst +++ b/docs/guides/tagger.rst @@ -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 diff --git a/docs/index.rst b/docs/index.rst index 2b2c2e723..e9dd3b34f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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 -------- diff --git a/docs/plugins/deezer.rst b/docs/plugins/deezer.rst index b3a57e825..96ed34652 100644 --- a/docs/plugins/deezer.rst +++ b/docs/plugins/deezer.rst @@ -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 diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index 3dac558bd..64b68248d 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -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 diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index 90b89ae89..1a95d03a8 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -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 ---------------- diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 64874dd32..a877d2320 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -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: diff --git a/docs/plugins/musicbrainz.rst b/docs/plugins/musicbrainz.rst index ed8eefa36..5ac287368 100644 --- a/docs/plugins/musicbrainz.rst +++ b/docs/plugins/musicbrainz.rst @@ -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 diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index 2c6cb3d1c..b72f22f20 100644 --- a/docs/plugins/spotify.rst +++ b/docs/plugins/spotify.rst @@ -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 diff --git a/docs/plugins/zero.rst b/docs/plugins/zero.rst index 6ed9427d9..bf134e664 100644 --- a/docs/plugins/zero.rst +++ b/docs/plugins/zero.rst @@ -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. diff --git a/docs/reference/config.rst b/docs/reference/config.rst index bc823ded4..eae9deb21 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -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 diff --git a/extra/release.py b/extra/release.py index b47de8966..d4ebb950f 100755 --- a/extra/release.py +++ b/extra/release.py @@ -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), ] diff --git a/poetry.lock b/poetry.lock index 36811b448..e125ac896 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 0407e8b22..98d316b95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/test/autotag/test_distance.py b/test/autotag/test_distance.py index e3ce9f891..213d32956 100644 --- a/test/autotag/test_distance.py +++ b/test/autotag/test_distance.py @@ -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 diff --git a/test/conftest.py b/test/conftest.py index 3107ad690..eb46b94b0 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -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() diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 3652c0a54..eb65bc588 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -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 diff --git a/test/plugins/test_fromfilename.py b/test/plugins/test_fromfilename.py new file mode 100644 index 000000000..f13e88aea --- /dev/null +++ b/test/plugins/test_fromfilename.py @@ -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] diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 005318b11..30b414948 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -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 diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index d6df42f97..12ff30f8e 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -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.""" diff --git a/test/plugins/test_zero.py b/test/plugins/test_zero.py index 51913c8e0..b08bf0dca 100644 --- a/test/plugins/test_zero.py +++ b/test/plugins/test_zero.py @@ -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" diff --git a/test/test_plugins.py b/test/test_plugins.py index df338f924..07bbf0966 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -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")