From c2ab93a9468f299011500b6cfa9ab000d955793a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 5 Oct 2025 10:49:44 +0100 Subject: [PATCH 01/83] Remove redundant source_weight defaults --- beetsplug/beatport.py | 1 - beetsplug/discogs.py | 1 - 2 files changed, 2 deletions(-) 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 878448556..874eab6ec 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -129,7 +129,6 @@ class DiscogsPlugin(MetadataSourcePlugin): "apikey": API_KEY, "apisecret": API_SECRET, "tokenfile": "discogs_token.json", - "source_weight": 0.5, "user_token": "", "separator": ", ", "index_tracks": False, From 6e5af90abb129f51ba19f1df91aef1d1d27124e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 7 Oct 2025 13:45:06 +0100 Subject: [PATCH 02/83] Rename source_weight -> data_source_mismatch_penalty --- beets/metadata_plugins.py | 4 ++-- docs/plugins/index.rst | 6 +++--- docs/plugins/spotify.rst | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 56bf8124f..3da137b51 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -133,7 +133,7 @@ def _get_distance( dist = Distance() if info.data_source == data_source: - dist.add("source", config["source_weight"].as_number()) + dist.add("source", config["data_source_mismatch_penalty"].as_number()) return dist @@ -150,7 +150,7 @@ class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta): self.config.add( { "search_limit": 5, - "source_weight": 0.5, + "data_source_mismatch_penalty": 0.5, } ) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 64874dd32..52009ed84 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -50,8 +50,8 @@ Using Metadata Source Plugins Some plugins provide sources for metadata in addition to MusicBrainz. These plugins share the following configuration option: -- **source_weight**: Penalty applied to matches during import. Set to 0.0 to - disable. Default: ``0.5``. +- **data_source_mismatch_penalty**: Penalty applied to matches during import. + Set to 0.0 to disable. Default: ``0.5``. For example, to equally consider matches from Discogs and MusicBrainz add the following to your configuration: @@ -61,7 +61,7 @@ following to your configuration: plugins: musicbrainz discogs discogs: - source_weight: 0.0 + data_source_mismatch_penalty: 0.0 .. toctree:: :hidden: diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index 2c6cb3d1c..33d8f1051 100644 --- a/docs/plugins/spotify.rst +++ b/docs/plugins/spotify.rst @@ -106,7 +106,7 @@ Here's an example: :: spotify: - source_weight: 0.7 + data_source_mismatch_penalty: 0.7 mode: open region_filter: US show_failures: on From 60e0efb8ea5aaebdd6f27c03a98c645a05466241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 5 Oct 2025 13:02:13 +0100 Subject: [PATCH 03/83] Make naming consistent with the field name --- beets/config_default.yaml | 2 +- beets/metadata_plugins.py | 4 +++- docs/changelog.rst | 3 +++ docs/reference/config.rst | 2 +- test/autotag/test_distance.py | 4 ++-- 5 files changed, 10 insertions(+), 5 deletions(-) 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/metadata_plugins.py b/beets/metadata_plugins.py index 3da137b51..780fe30b7 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -133,7 +133,9 @@ def _get_distance( dist = Distance() if info.data_source == data_source: - dist.add("source", config["data_source_mismatch_penalty"].as_number()) + dist.add( + "data_source", config["data_source_mismatch_penalty"].as_number() + ) return dist diff --git a/docs/changelog.rst b/docs/changelog.rst index b56413ee9..7418b51db 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -66,6 +66,9 @@ 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: diff --git a/docs/reference/config.rst b/docs/reference/config.rst index bc823ded4..30582d12c 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -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/test/autotag/test_distance.py b/test/autotag/test_distance.py index e3ce9f891..ffbb24eca 100644 --- a/test/autotag/test_distance.py +++ b/test/autotag/test_distance.py @@ -22,7 +22,7 @@ class TestDistance: @pytest.fixture def dist(self, config): - config["match"]["distance_weights"]["source"] = 2.0 + config["match"]["distance_weights"]["data_source"] = 2.0 config["match"]["distance_weights"]["album"] = 4.0 config["match"]["distance_weights"]["medium"] = 2.0 @@ -103,7 +103,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) From e6084cd3ee0aee6c7d232474909a77117deb9d84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 5 Oct 2025 17:52:09 +0100 Subject: [PATCH 04/83] Set default data_source_penalty to 0.0 --- beets/metadata_plugins.py | 2 +- docs/plugins/index.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 780fe30b7..7e84167ff 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -152,7 +152,7 @@ class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta): self.config.add( { "search_limit": 5, - "data_source_mismatch_penalty": 0.5, + "data_source_mismatch_penalty": 0.0, } ) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 52009ed84..1374475cc 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -51,7 +51,7 @@ Some plugins provide sources for metadata in addition to MusicBrainz. These plugins share the following configuration option: - **data_source_mismatch_penalty**: Penalty applied to matches during import. - Set to 0.0 to disable. Default: ``0.5``. + Set to 0.0 to disable. Default: ``0.0``. For example, to equally consider matches from Discogs and MusicBrainz add the following to your configuration: From 203c2176d91a2160e056b6bac17a3b8011164810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 5 Oct 2025 20:02:14 +0100 Subject: [PATCH 05/83] Update data_source_penalty docs --- docs/plugins/index.rst | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 1374475cc..1e2f5d5e8 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -47,21 +47,27 @@ 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: - **data_source_mismatch_penalty**: Penalty applied to matches during import. - Set to 0.0 to disable. Default: ``0.0``. + Default: ``0.0`` (no penalty). -For example, to equally consider matches from Discogs and MusicBrainz add the -following to your configuration: + Penalize this data source to prioritize others. For example, to prefer Discogs + over MusicBrainz: -.. code-block:: yaml + .. code-block:: yaml - plugins: musicbrainz discogs + plugins: musicbrainz discogs - discogs: - data_source_mismatch_penalty: 0.0 + musicbrainz: + data_source_mismatch_penalty: 2.0 + + By default, all sources are equally preferred with each having + ``data_source_mismatch_penalty`` set to ``0.0``. + +- **search_limit**: Maximum number of search results to consider. Default: + ``5``. .. toctree:: :hidden: From 01e2eb4665529c0c6ca4f3f805b931455e55c58e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 5 Oct 2025 21:19:27 +0100 Subject: [PATCH 06/83] Add default config yaml to each data source docs --- docs/plugins/deezer.rst | 15 ++++++----- docs/plugins/discogs.rst | 50 +++++++++++++++++++----------------- docs/plugins/musicbrainz.rst | 6 ++++- docs/plugins/spotify.rst | 24 ++++++++++++----- 4 files changed, 59 insertions(+), 36 deletions(-) diff --git a/docs/plugins/deezer.rst b/docs/plugins/deezer.rst index b3a57e825..805c31852 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.0 + 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 0d55630c4..ab7a30c59 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.0 + 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,9 +113,6 @@ 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`` diff --git a/docs/plugins/musicbrainz.rst b/docs/plugins/musicbrainz.rst index ed8eefa36..4fc4e4092 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.0 + search_limit: 5 host: musicbrainz.org https: no ratelimit: 1 ratelimit_interval: 1.0 - search_limit: 5 extra_tags: [] genres: no external_ids: diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index 33d8f1051..2788c0515 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.0 + 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,8 +112,6 @@ 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: From 96670cf9710e3068122063f1ff8fa795afa3a1cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 7 Oct 2025 14:01:20 +0100 Subject: [PATCH 07/83] Cache found metadata source plugins --- beets/metadata_plugins.py | 29 +++++---------------------- beets/plugins.py | 42 ++++++++++++++++++++++++++++++++------- 2 files changed, 40 insertions(+), 31 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 7e84167ff..e765e4cbf 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -9,7 +9,7 @@ from __future__ import annotations import abc import re -import warnings +from functools import cache from typing import TYPE_CHECKING, Generic, Literal, Sequence, TypedDict, TypeVar import unidecode @@ -29,30 +29,11 @@ if TYPE_CHECKING: 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") diff --git a/beets/plugins.py b/beets/plugins.py index c0dd12e5b..5d3e39cc7 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -20,6 +20,7 @@ import abc import inspect import re import sys +import warnings from collections import defaultdict from functools import wraps from importlib import import_module @@ -160,19 +161,46 @@ 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, + ) + + for name, method in inspect.getmembers( + MetadataSourcePlugin, + predicate=lambda f: ( + inspect.isfunction(f) + and f.__name__ not in MetadataSourcePlugin.__abstractmethods__ + and not hasattr(cls, f.__name__) + ), + ): + setattr(cls, name, method) def __init__(self, name: str | None = None): """Perform one-time plugin setup.""" From 455d620ae08448065c8c185ae28eac27321f0e60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 6 Oct 2025 07:48:50 +0100 Subject: [PATCH 08/83] Fix data source penalty application logic The data_source penalty was not being calculated correctly because `_get_distance` was being called for **all** enabled metadata plugins which eventually meant that matches were being penalised needlessly. This commit refactors the distance calculation to: - Remove the plugin-based track_distance() and album_distance() methods that were applying penalties incorrectly - Calculate data_source penalties directly in track_distance() and distance() functions when sources don't match - Use a centralized get_penalty() function to retrieve plugin-specific penalty values via a registry with O(1) lookup - Change default data_source_penalty from 0.0 to 0.5 to ensure mismatches are penalized by default - Add data_source to get_most_common_tags() to determine the likely original source for comparison This ensures that tracks and albums from different data sources are properly penalized during matching, improving match quality and preventing cross-source matches. --- beets/autotag/distance.py | 12 +++- beets/metadata_plugins.py | 103 +++++++++------------------------- beets/util/__init__.py | 3 +- docs/changelog.rst | 11 ++++ docs/plugins/deezer.rst | 2 +- docs/plugins/discogs.rst | 2 +- docs/plugins/index.rst | 4 +- docs/plugins/musicbrainz.rst | 2 +- docs/plugins/spotify.rst | 2 +- test/autotag/test_distance.py | 53 +++++++++++++++++ 10 files changed, 108 insertions(+), 86 deletions(-) diff --git a/beets/autotag/distance.py b/beets/autotag/distance.py index 727439ea3..123f4b788 100644 --- a/beets/autotag/distance.py +++ b/beets/autotag/distance.py @@ -409,7 +409,10 @@ def track_distance( dist.add_expr("medium", item.disc != track_info.medium) # Plugins. - dist.update(metadata_plugins.track_distance(item, track_info)) + if (original := item.get("data_source")) and ( + actual := track_info.data_source + ) != original: + dist.add("data_source", metadata_plugins.get_penalty(actual)) return dist @@ -526,6 +529,9 @@ def distance( dist.add("unmatched_tracks", 1.0) # Plugins. - dist.update(metadata_plugins.album_distance(items, album_info, mapping)) - + if ( + likelies["data_source"] + and (data_source := album_info.data_source) != likelies["data_source"] + ): + dist.add("data_source", metadata_plugins.get_penalty(data_source)) return dist diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index e765e4cbf..5e0d8570d 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -9,7 +9,7 @@ from __future__ import annotations import abc import re -from functools import cache +from functools import cache, cached_property from typing import TYPE_CHECKING, Generic, Literal, Sequence, TypedDict, TypeVar import unidecode @@ -23,9 +23,6 @@ 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 @@ -76,48 +73,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( - "data_source", config["data_source_mismatch_penalty"].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): @@ -128,12 +94,26 @@ 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: + 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, - "data_source_mismatch_penalty": 0.0, + "data_source_mismatch_penalty": self.DEFAULT_DATA_SOURCE_MISMATCH_PENALTY, # noqa: E501 } ) @@ -207,35 +187,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/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/docs/changelog.rst b/docs/changelog.rst index 7418b51db..f807af8c9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -45,6 +45,10 @@ Bug fixes: an import of another :class:`beets.plugins.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` For packagers: @@ -75,6 +79,13 @@ 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) -------------------------- diff --git a/docs/plugins/deezer.rst b/docs/plugins/deezer.rst index 805c31852..96ed34652 100644 --- a/docs/plugins/deezer.rst +++ b/docs/plugins/deezer.rst @@ -35,7 +35,7 @@ Default .. code-block:: yaml deezer: - data_source_mismatch_penalty: 0.0 + data_source_mismatch_penalty: 0.5 search_limit: 5 search_query_ascii: no diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index ab7a30c59..64b68248d 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -71,7 +71,7 @@ Default .. code-block:: yaml discogs: - data_source_mismatch_penalty: 0.0 + data_source_mismatch_penalty: 0.5 search_limit: 5 apikey: REDACTED apisecret: REDACTED diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 1e2f5d5e8..1e1ed43da 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -51,7 +51,7 @@ We provide several :ref:`autotagger_extensions` that fetch metadata from online databases. They share the following configuration options: - **data_source_mismatch_penalty**: Penalty applied to matches during import. - Default: ``0.0`` (no penalty). + Any decimal number between 0 and 1. Default: ``0.5``. Penalize this data source to prioritize others. For example, to prefer Discogs over MusicBrainz: @@ -64,7 +64,7 @@ databases. They share the following configuration options: data_source_mismatch_penalty: 2.0 By default, all sources are equally preferred with each having - ``data_source_mismatch_penalty`` set to ``0.0``. + ``data_source_mismatch_penalty`` set to ``0.5``. - **search_limit**: Maximum number of search results to consider. Default: ``5``. diff --git a/docs/plugins/musicbrainz.rst b/docs/plugins/musicbrainz.rst index 4fc4e4092..110d9b92c 100644 --- a/docs/plugins/musicbrainz.rst +++ b/docs/plugins/musicbrainz.rst @@ -26,7 +26,7 @@ Default .. code-block:: yaml musicbrainz: - data_source_mismatch_penalty: 0.0 + data_source_mismatch_penalty: 0.5 search_limit: 5 host: musicbrainz.org https: no diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index 2788c0515..b72f22f20 100644 --- a/docs/plugins/spotify.rst +++ b/docs/plugins/spotify.rst @@ -73,7 +73,7 @@ Default .. code-block:: yaml spotify: - data_source_mismatch_penalty: 0.0 + data_source_mismatch_penalty: 0.5 search_limit: 5 mode: list region_filter: diff --git a/test/autotag/test_distance.py b/test/autotag/test_distance.py index ffbb24eca..5a3a8bee2 100644 --- a/test/autotag/test_distance.py +++ b/test/autotag/test_distance.py @@ -10,6 +10,7 @@ from beets.autotag.distance import ( track_distance, ) from beets.library import Item +from beets.metadata_plugins import MetadataSourcePlugin, get_penalty from beets.test.helper import ConfigMixin _p = pytest.param @@ -297,3 +298,55 @@ 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): + 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): ... + + class OriginalPlugin(TestMetadataSourcePlugin): + pass + + class OtherPlugin(TestMetadataSourcePlugin): + @property + def data_source_mismatch_penalty(self): + return penalty + + monkeypatch.setattr( + "beets.metadata_plugins.find_metadata_source_plugins", + lambda: [OriginalPlugin(), OtherPlugin()], + ) + + @pytest.mark.parametrize( + "item,info,penalty,weight,expected_distance", + [ + _p("Original", "Original", 0.5, 1.0, MATCH, id="match"), + _p("Original", "Other", 0.5, 1.0, MISMATCH, id="mismatch"), + _p("Original", "unknown", 0.5, 1.0, MISMATCH, id="mismatch-unknown"), # noqa: E501 + _p("Original", None, 0.5, 1.0, MISMATCH, id="mismatch-no-info"), + _p(None, "Other", 0.5, 1.0, MATCH, id="match-no-original"), + _p("unknown", "unknown", 0.5, 1.0, MATCH, id="match-unknown"), + _p("Original", "Other", 1.0, 1.0, 0.25, id="mismatch-max-penalty"), + _p("Original", "Other", 0.5, 5.0, 0.3125, id="mismatch-high-weight"), # noqa: E501 + _p("Original", "Other", 0.0, 1.0, MATCH, id="match-no-penalty"), + _p("Original", "Other", 0.5, 0.0, MATCH, id="match-no-weight"), + ], + ) # 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 From e6895bb52d3e4233fe76dedcd2ba6bbcb8b4f7f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 6 Oct 2025 09:00:46 +0100 Subject: [PATCH 09/83] Reset cached_classproperty cache for every test --- beets/test/helper.py | 2 -- test/autotag/test_distance.py | 2 -- test/conftest.py | 6 ++++++ 3 files changed, 6 insertions(+), 4 deletions(-) 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/test/autotag/test_distance.py b/test/autotag/test_distance.py index 5a3a8bee2..8c4478ca9 100644 --- a/test/autotag/test_distance.py +++ b/test/autotag/test_distance.py @@ -27,8 +27,6 @@ class TestDistance: config["match"]["distance_weights"]["album"] = 4.0 config["match"]["distance_weights"]["medium"] = 2.0 - Distance.__dict__["_weights"].cache = {} - return Distance() def test_add(self, dist): diff --git a/test/conftest.py b/test/conftest.py index 3107ad690..e1350b092 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -4,6 +4,7 @@ import os import pytest 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 +42,8 @@ def pytest_make_parametrize_id(config, val, argname): return inspect.getsource(val).split("lambda")[-1][:30] return repr(val) + + +@pytest.fixture(autouse=True) +def clear_cached_classproperty(): + cached_classproperty.cache.clear() From 5757579e275e61ff4b62a4294c94279300ca2f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 6 Oct 2025 09:01:33 +0100 Subject: [PATCH 10/83] Improve visibility of Distance tests failures --- test/autotag/test_distance.py | 17 +++++++---------- test/conftest.py | 6 ++++++ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/test/autotag/test_distance.py b/test/autotag/test_distance.py index 8c4478ca9..72922470b 100644 --- a/test/autotag/test_distance.py +++ b/test/autotag/test_distance.py @@ -17,16 +17,15 @@ _p = pytest.param class TestDistance: - @pytest.fixture(scope="class") - def config(self): - return ConfigMixin().config - - @pytest.fixture - def dist(self, config): + @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 + @pytest.fixture + def dist(self): return Distance() def test_add(self, dist): @@ -161,10 +160,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: diff --git a/test/conftest.py b/test/conftest.py index e1350b092..eb46b94b0 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -3,6 +3,7 @@ import os import pytest +from beets.autotag.distance import Distance from beets.dbcore.query import Query from beets.util import cached_classproperty @@ -44,6 +45,11 @@ def pytest_make_parametrize_id(config, val, argname): 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() From f8887d48b6b3677bc7462d0bcddfa8a2da6d9c20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 7 Oct 2025 14:19:26 +0100 Subject: [PATCH 11/83] Add deprecation warning for .source_weight --- beets/metadata_plugins.py | 6 +++++- beets/plugins.py | 31 +++++++++++++++++++++++++++++++ docs/plugins/index.rst | 6 ++++++ docs/plugins/musicbrainz.rst | 2 +- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/beets/metadata_plugins.py b/beets/metadata_plugins.py index 5e0d8570d..b865167e4 100644 --- a/beets/metadata_plugins.py +++ b/beets/metadata_plugins.py @@ -13,6 +13,7 @@ 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 @@ -106,7 +107,10 @@ class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta): @cached_property def data_source_mismatch_penalty(self) -> float: - return self.config["data_source_mismatch_penalty"].as_number() + 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) diff --git a/beets/plugins.py b/beets/plugins.py index 5d3e39cc7..7fa0e660a 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -225,6 +225,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. diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 1e1ed43da..7b595ac86 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -50,6 +50,8 @@ Using Metadata Source Plugins We provide several :ref:`autotagger_extensions` that fetch metadata from online databases. They share the following configuration options: +.. _data_source_mismatch_penalty: + - **data_source_mismatch_penalty**: Penalty applied to matches during import. Any decimal number between 0 and 1. Default: ``0.5``. @@ -66,6 +68,10 @@ databases. They share the following configuration options: By default, all sources are equally preferred with each having ``data_source_mismatch_penalty`` set to ``0.5``. +- **source_weight** + + .. deprecated:: 2.5 Use `data_source_mismatch_penalty`_ instead. + - **search_limit**: Maximum number of search results to consider. Default: ``5``. diff --git a/docs/plugins/musicbrainz.rst b/docs/plugins/musicbrainz.rst index 110d9b92c..5ac287368 100644 --- a/docs/plugins/musicbrainz.rst +++ b/docs/plugins/musicbrainz.rst @@ -78,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 From 1f62a928ec02c5ba78d012b00798465e4915731a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 8 Oct 2025 22:55:48 +0100 Subject: [PATCH 12/83] Update data source documentation --- docs/plugins/index.rst | 54 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 7b595ac86..bf5106e9a 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -52,21 +52,61 @@ databases. They share the following configuration options: .. _data_source_mismatch_penalty: -- **data_source_mismatch_penalty**: Penalty applied to matches during import. - Any decimal number between 0 and 1. Default: ``0.5``. +- **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``. - Penalize this data source to prioritize others. For example, to prefer Discogs - over MusicBrainz: + 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. + + .. important:: + + This setting only applies to reimports, not to first-time imports, since + ``data_source`` is unknown for new files. + + **Example configurations:** .. code-block:: yaml + # Prefer MusicBrainz over Discogs when sources don't match plugins: musicbrainz discogs musicbrainz: - data_source_mismatch_penalty: 2.0 + data_source_mismatch_penalty: 0.3 # Lower penalty = preferred + discogs: + data_source_mismatch_penalty: 0.8 # Higher penalty = less preferred - By default, all sources are equally preferred with each having - ``data_source_mismatch_penalty`` set to ``0.5``. + .. 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** From 90ca0a799ac60a2b2f5a67281d715c4303664564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 9 Oct 2025 04:36:03 +0100 Subject: [PATCH 13/83] Consider unseen tracks in data source matching --- beets/autotag/distance.py | 9 ++------- docs/plugins/index.rst | 5 ----- test/autotag/test_distance.py | 2 +- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/beets/autotag/distance.py b/beets/autotag/distance.py index 123f4b788..e5ec2debb 100644 --- a/beets/autotag/distance.py +++ b/beets/autotag/distance.py @@ -409,9 +409,7 @@ def track_distance( dist.add_expr("medium", item.disc != track_info.medium) # Plugins. - if (original := item.get("data_source")) and ( - actual := track_info.data_source - ) != original: + if (actual := track_info.data_source) != item.get("data_source"): dist.add("data_source", metadata_plugins.get_penalty(actual)) return dist @@ -529,9 +527,6 @@ def distance( dist.add("unmatched_tracks", 1.0) # Plugins. - if ( - likelies["data_source"] - and (data_source := album_info.data_source) != likelies["data_source"] - ): + if (data_source := album_info.data_source) != likelies["data_source"]: dist.add("data_source", metadata_plugins.get_penalty(data_source)) return dist diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index bf5106e9a..a877d2320 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -61,11 +61,6 @@ databases. They share the following configuration options: candidate comes from a different data source than what appears to be the original source of your music collection. - .. important:: - - This setting only applies to reimports, not to first-time imports, since - ``data_source`` is unknown for new files. - **Example configurations:** .. code-block:: yaml diff --git a/test/autotag/test_distance.py b/test/autotag/test_distance.py index 72922470b..91003bbb9 100644 --- a/test/autotag/test_distance.py +++ b/test/autotag/test_distance.py @@ -330,7 +330,7 @@ class TestDataSourceDistance: _p("Original", "Other", 0.5, 1.0, MISMATCH, id="mismatch"), _p("Original", "unknown", 0.5, 1.0, MISMATCH, id="mismatch-unknown"), # noqa: E501 _p("Original", None, 0.5, 1.0, MISMATCH, id="mismatch-no-info"), - _p(None, "Other", 0.5, 1.0, MATCH, id="match-no-original"), + _p(None, "Other", 0.5, 1.0, MISMATCH, id="mismatch-no-original"), _p("unknown", "unknown", 0.5, 1.0, MATCH, id="match-unknown"), _p("Original", "Other", 1.0, 1.0, 0.25, id="mismatch-max-penalty"), _p("Original", "Other", 0.5, 5.0, 0.3125, id="mismatch-high-weight"), # noqa: E501 From 3b38045d01d400245bdb6d6fd461ec934d684a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 10 Oct 2025 20:35:35 +0100 Subject: [PATCH 14/83] Only penalize multi data sources on first import --- beets/autotag/distance.py | 15 +++++++++------ test/autotag/test_distance.py | 31 ++++++++++++++++++------------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/beets/autotag/distance.py b/beets/autotag/distance.py index e5ec2debb..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,9 +414,7 @@ def track_distance( if track_info.medium and item.disc: dist.add_expr("medium", item.disc != track_info.medium) - # Plugins. - if (actual := track_info.data_source) != item.get("data_source"): - dist.add("data_source", metadata_plugins.get_penalty(actual)) + dist.add_data_source(item.get("data_source"), track_info.data_source) return dist @@ -526,7 +530,6 @@ def distance( for _ in range(len(items) - len(mapping)): dist.add("unmatched_tracks", 1.0) - # Plugins. - if (data_source := album_info.data_source) != likelies["data_source"]: - dist.add("data_source", metadata_plugins.get_penalty(data_source)) + dist.add_data_source(likelies["data_source"], album_info.data_source) + return dist diff --git a/test/autotag/test_distance.py b/test/autotag/test_distance.py index 91003bbb9..b327bbe44 100644 --- a/test/autotag/test_distance.py +++ b/test/autotag/test_distance.py @@ -300,7 +300,7 @@ class TestDataSourceDistance: MISMATCH = 0.125 @pytest.fixture(autouse=True) - def setup(self, monkeypatch, penalty, weight): + def setup(self, monkeypatch, penalty, weight, multiple_data_sources): monkeypatch.setitem(Distance._weights, "data_source", weight) get_penalty.cache_clear() @@ -320,22 +320,27 @@ class TestDataSourceDistance: monkeypatch.setattr( "beets.metadata_plugins.find_metadata_source_plugins", - lambda: [OriginalPlugin(), OtherPlugin()], + lambda: ( + [OriginalPlugin(), OtherPlugin()] + if multiple_data_sources + else [OtherPlugin()] + ), ) @pytest.mark.parametrize( - "item,info,penalty,weight,expected_distance", + "item,info,penalty,weight,multiple_data_sources,expected_distance", [ - _p("Original", "Original", 0.5, 1.0, MATCH, id="match"), - _p("Original", "Other", 0.5, 1.0, MISMATCH, id="mismatch"), - _p("Original", "unknown", 0.5, 1.0, MISMATCH, id="mismatch-unknown"), # noqa: E501 - _p("Original", None, 0.5, 1.0, MISMATCH, id="mismatch-no-info"), - _p(None, "Other", 0.5, 1.0, MISMATCH, id="mismatch-no-original"), - _p("unknown", "unknown", 0.5, 1.0, MATCH, id="match-unknown"), - _p("Original", "Other", 1.0, 1.0, 0.25, id="mismatch-max-penalty"), - _p("Original", "Other", 0.5, 5.0, 0.3125, id="mismatch-high-weight"), # noqa: E501 - _p("Original", "Other", 0.0, 1.0, MATCH, id="match-no-penalty"), - _p("Original", "Other", 0.5, 0.0, MATCH, id="match-no-weight"), + _p("Original", "Original", 0.5, 1.0, True, MATCH, id="match"), + _p("Original", "Other", 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): From 6faa4f3ddde6494f9586a35559d1c1b2795d8949 Mon Sep 17 00:00:00 2001 From: semohr Date: Sat, 11 Oct 2025 09:58:48 +0000 Subject: [PATCH 15/83] Increment version to 2.5.0 --- docs/changelog.rst | 11 +++++++++++ docs/conf.py | 4 ++-- pyproject.toml | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f807af8c9..23278bb36 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,17 @@ Unreleased New features: +Bug fixes: + +For packagers: + +Other changes: + +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 diff --git a/docs/conf.py b/docs/conf.py index 7465bdb27..c76f87524 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.0" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/pyproject.toml b/pyproject.toml index 8338ce1c6..62b5ac25a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "beets" -version = "2.4.0" +version = "2.5.0" description = "music tagger and library organizer" authors = ["Adrian Sampson "] maintainers = ["Serene-Arc"] From 116357e2f6585b538d8d5b0657d9ce2b73ff6f18 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 27 May 2025 13:29:25 +0200 Subject: [PATCH 16/83] Removed outdated installation instructions. - macport: stuck on 1.6 - slackware: stuck on 1.6 - OpenBSD: stuck on 1.6 Remove twitter reference. Removed mailing list reference. --- docs/faq.rst | 51 ++++++++++-------- docs/guides/main.rst | 118 ++++++++++++++++++----------------------- docs/guides/tagger.rst | 2 - 3 files changed, 82 insertions(+), 89 deletions(-) diff --git a/docs/faq.rst b/docs/faq.rst index 3e527e8bc..40da1216b 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -163,31 +163,38 @@ documentation ` 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: +We use the `issue tracker`_ on GitHub where you can `open a new ticket`_. +Please follow these guidelines when reporting an issue: -- Most importantly: if beets is crashing, please `include the traceback - `__. Tracebacks can be more readable if you put them - in a pastebin (e.g., `Gist `__ or `Hastebin - `__), especially when communicating over IRC or email. -- 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. -- If you can, try installing the latest beets source code to see if the bug is - fixed in an unreleased version. You can also look at the :doc:`latest - changelog entries ` for descriptions of the problem you're seeing. -- Try to narrow your problem down to something specific. Is a particular plugin - causing the problem? (You can disable plugins to see whether the problem goes - away.) Is a some music file or a single album leading to the crash? (Try - importing individual albums to determine which one is causing the problem.) Is - some entry in your configuration file causing it? Et cetera. -- If you do narrow the problem down to a particular audio file or album, include - it with your bug report so the developers can run tests. +- Most importantly: if beets is crashing, please `include the + traceback `__. Tracebacks can be more + readable if you put them in a pastebin (e.g., + `Gist `__ or + `Hastebin `__), 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. +- If you can, try installing the latest beets source code to see if the + bug is fixed in an unreleased version. You can also look at the + :doc:`latest changelog entries ` + for descriptions of the problem you're seeing. +- Try to narrow your problem down to something specific. Is a + particular plugin causing the problem? (You can disable plugins to + see whether the problem goes away.) Is a some music file or a single + album leading to the crash? (Try importing individual albums to + determine which one is causing the problem.) Is some entry in your + configuration file causing it? Et cetera. +- If you do narrow the problem down to a particular audio file or + album, include it with your bug report so the developers can run + tests. -If you've never reported a bug before, Mozilla has some well-written `general -guidelines for good bug reports`_. +If you've never reported a bug before, Mozilla has some well-written +`general guidelines for good bug +reports`_. .. _find-config: diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 0b502bfb1..1c6454958 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -9,13 +9,36 @@ collection better. Installing ---------- -You will need Python. Beets works on Python 3.8 or later. +Beets requires Python 3.9 or later, you will need to install that first. Depending +on your operating system, you may also be able to install beets from a package +manager, or you can install it with `pip`_. -- **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 +Using pip +^^^^^^^^^ + +To use the most recent version of beets, we recommend installing it with +`pip`_, the Python package manager. If you don't have `pip`_ installed, you can +follow the instructions on the `pip installation page`_ to get it set up. + +.. code-block:: console + + pip install beets + # or, to install for the current user only: + pip install --user beets + + +.. attention:: Python 3.13 not officially supported yet! + + If you are using Python 3.13, please be aware that it is not yet officially supported yet. + You may encounter issues, and we recommend using Python 3.12 or earlier until support is confirmed. + + +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: + +* 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 @@ -55,48 +78,16 @@ You will need Python. Beets works on Python 3.8 or later. .. _macports: https://www.macports.org -.. _nixos: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets - -.. _openbsd: http://openports.se/audio/beets - -.. _slackbuild: https://slackbuilds.org/repository/14.2/multimedia/beets/ - -.. _ubuntu details: https://launchpad.net/ubuntu/+source/beets - -.. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets - -If you have pip_, just say ``pip install beets`` (or ``pip install --user -beets`` if you run into permissions problems). - -To install without pip, download beets from `its PyPI page`_ and run ``python -setup.py install`` in the directory therein. - -.. _its pypi page: https://pypi.org/project/beets/#files - -.. _pip: https://pip.pypa.io - -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. - -.. _@b33ts: https://twitter.com/b33ts - -Installing by Hand on macOS 10.11 and Higher -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -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_.) - -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``. - -.. _homebrew: https://brew.sh - -.. _system integrity protection: https://support.apple.com/en-us/HT204899 +.. _DNF package: https://packages.fedoraproject.org/pkgs/beets/ +.. _FreeBSD: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets +.. _AUR: https://aur.archlinux.org/packages/beets-git/ +.. _Debian details: https://tracker.debian.org/pkg/beets +.. _Ubuntu details: https://launchpad.net/ubuntu/+source/beets +.. _Arch extra: https://archlinux.org/packages/extra/any/beets/ +.. _Alpine package: https://pkgs.alpinelinux.org/package/edge/community/x86_64/beets +.. _NixOS: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets +.. _pip: https://pip.pypa.io/en/ +.. _pip installation page: https://pip.pypa.io/en/stable/installation/ Installing on Windows ~~~~~~~~~~~~~~~~~~~~~ @@ -104,17 +95,18 @@ Installing on Windows Installing beets on Windows can be tricky. Following these steps might help you get it right: -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. +1. If you don't have it, `install Python`_ (you want at least Python 3.9). 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 + 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:\Python39;C:\Python39\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 @@ -126,9 +118,8 @@ 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". -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`_. +If you have trouble or you have more detail to contribute here, please direct it to +`the discussion board`_. .. _beets.reg: https://github.com/beetbox/beets/blob/master/extra/beets.reg @@ -348,8 +339,5 @@ blog `_. Please let us know what you think of beets via `the discussion board`_ or Mastodon_. -.. _mastodon: https://fosstodon.org/@beets - .. _the discussion board: https://github.com/beetbox/beets/discussions - -.. _the mailing list: https://groups.google.com/group/beets-users +.. _mastodon: https://fosstodon.org/@beets 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 From 103b501af790b54bc2e5c63df1f35287a8299947 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 27 May 2025 13:30:59 +0200 Subject: [PATCH 17/83] Removed mailing list ref in index.rst --- docs/index.rst | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 2b2c2e723..870f608c7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,12 +13,10 @@ Then you can get a more detailed look at beets' features in the be interested in exploring the :doc:`plugins `. 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/ - .. _file a bug: https://github.com/beetbox/beets/issues .. _the discussion board: https://github.com/beetbox/beets/discussions/ From 3b5eee59eef79a7236e068409301aac71beb01ae Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 27 May 2025 13:32:50 +0200 Subject: [PATCH 18/83] Added changelog entry. --- docs/changelog.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 23278bb36..fd72f4f7a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,6 +15,9 @@ For packagers: Other changes: +- Removed old mailing list contact info in docs :bug:`5462` +- :doc:`guides/main`: Modernized getting started guide using tabs and dropdown menue. Installtion instructions are now more condensed and there is a subpage for additional instructions. + 2.5.0 (October 11, 2025) ------------------------ @@ -61,8 +64,6 @@ Bug fixes: configuration option has been renamed to ``data_source_mismatch_penalty`` to better reflect its purpose. :bug:`6066` -For packagers: - Other changes: - :doc:`plugins/index`: Clarify that musicbrainz must be mentioned if plugin From 81c622bcecf442f4eda4dc184d4ae7dcaf3fd389 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 27 May 2025 13:38:25 +0200 Subject: [PATCH 19/83] Removed duplicate yet. --- docs/guides/main.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 1c6454958..99fa9be91 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -29,7 +29,7 @@ follow the instructions on the `pip installation page`_ to get it set up. .. attention:: Python 3.13 not officially supported yet! - If you are using Python 3.13, please be aware that it is not yet officially supported yet. + If you are using Python 3.13, please be aware that it is not officially supported yet. You may encounter issues, and we recommend using Python 3.12 or earlier until support is confirmed. From 1aaaeb49ed595dab16350f9f3b51b06ac357e7a5 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Sat, 31 May 2025 21:35:57 +0200 Subject: [PATCH 20/83] Added pipx refernces --- docs/guides/main.rst | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 99fa9be91..abb6d5b3e 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -11,14 +11,20 @@ Installing Beets requires Python 3.9 or later, you will need to install that first. Depending on your operating system, you may also be able to install beets from a package -manager, or you can install it with `pip`_. +manager, or you can install it with `pipx`_ or `pip`_. Using pip ^^^^^^^^^ To use the most recent version of beets, we recommend installing it with -`pip`_, the Python package manager. If you don't have `pip`_ installed, you can -follow the instructions on the `pip installation page`_ to get it set up. +`pipx`_, the Python package manager. If you don't have `pipx`_ installed, you can +follow the instructions on the `pipx installation page`_ to get it set up. + +.. code-block:: console + + pipx install beets + +If you prefer to use `pip`_, you can install beets with the following command: .. code-block:: console @@ -87,7 +93,8 @@ Depending on your operating system, you may be able to install beets using a pac .. _Alpine package: https://pkgs.alpinelinux.org/package/edge/community/x86_64/beets .. _NixOS: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets .. _pip: https://pip.pypa.io/en/ -.. _pip installation page: https://pip.pypa.io/en/stable/installation/ +.. _pipx: https://pipx.pypa.io/stable +.. _pipx installation page: https://pipx.pypa.io/stable/installation/ Installing on Windows ~~~~~~~~~~~~~~~~~~~~~ From e30772f0c1254a7d0084e5d01f3eb69817d9c25e Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Mon, 8 Sep 2025 15:33:54 +0200 Subject: [PATCH 21/83] Run formatter. --- docs/faq.rst | 49 ++++++++++------------ docs/guides/main.rst | 97 ++++++++++++++++++++++++-------------------- docs/index.rst | 5 ++- 3 files changed, 77 insertions(+), 74 deletions(-) diff --git a/docs/faq.rst b/docs/faq.rst index 40da1216b..287dc88af 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -165,36 +165,29 @@ documentation ` pages. …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: +We use the `issue tracker`_ on GitHub where you can `open a new ticket`_. Please +follow these guidelines when reporting an issue: -- Most importantly: if beets is crashing, please `include the - traceback `__. Tracebacks can be more - readable if you put them in a pastebin (e.g., - `Gist `__ or - `Hastebin `__), 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. -- If you can, try installing the latest beets source code to see if the - bug is fixed in an unreleased version. You can also look at the - :doc:`latest changelog entries ` - for descriptions of the problem you're seeing. -- Try to narrow your problem down to something specific. Is a - particular plugin causing the problem? (You can disable plugins to - see whether the problem goes away.) Is a some music file or a single - album leading to the crash? (Try importing individual albums to - determine which one is causing the problem.) Is some entry in your - configuration file causing it? Et cetera. -- If you do narrow the problem down to a particular audio file or - album, include it with your bug report so the developers can run - tests. +- Most importantly: if beets is crashing, please `include the traceback + `__. Tracebacks can be more readable if you put them + in a pastebin (e.g., `Gist `__ or `Hastebin + `__), 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. +- If you can, try installing the latest beets source code to see if the bug is + fixed in an unreleased version. You can also look at the :doc:`latest + changelog entries ` for descriptions of the problem you're seeing. +- Try to narrow your problem down to something specific. Is a particular plugin + causing the problem? (You can disable plugins to see whether the problem goes + away.) Is a some music file or a single album leading to the crash? (Try + importing individual albums to determine which one is causing the problem.) Is + some entry in your configuration file causing it? Et cetera. +- If you do narrow the problem down to a particular audio file or album, include + it with your bug report so the developers can run tests. -If you've never reported a bug before, Mozilla has some well-written -`general guidelines for good bug -reports`_. +If you've never reported a bug before, Mozilla has some well-written `general +guidelines for good bug reports`_. .. _find-config: diff --git a/docs/guides/main.rst b/docs/guides/main.rst index abb6d5b3e..070ab0e2c 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -9,22 +9,22 @@ collection better. Installing ---------- -Beets requires Python 3.9 or later, you will need to install that first. Depending -on your operating system, you may also be able to install beets from a package -manager, or you can install it with `pipx`_ or `pip`_. +Beets requires Python 3.9 or later, you will need to install that first. +Depending on your operating system, you may also be able to install beets from a +package manager, or you can install it with pipx_ or pip_. -Using pip -^^^^^^^^^ +Using pip(x) +~~~~~~~~~~~~ -To use the most recent version of beets, we recommend installing it with -`pipx`_, the Python package manager. If you don't have `pipx`_ installed, you can -follow the instructions on the `pipx installation page`_ to get it set up. +To use the most recent version of beets, we recommend installing it with pipx_, +the Python package manager. If you don't have pipx_ installed, you can follow +the instructions on the `pipx installation page`_ to get it set up. .. code-block:: console pipx install beets -If you prefer to use `pip`_, you can install beets with the following command: +If you prefer to use pip_, you can install beets with the following command: .. code-block:: console @@ -32,19 +32,35 @@ If you prefer to use `pip`_, you can install beets with the following command: # or, to install for the current user only: pip install --user beets +.. attention:: -.. attention:: Python 3.13 not officially supported yet! - - If you are using Python 3.13, please be aware that it is not officially supported yet. - You may encounter issues, and we recommend using Python 3.12 or earlier until support is confirmed. + Python 3.13 is not officially supported yet! + If you are using Python 3.13, please be aware that it is not officially + supported yet. You may encounter issues, and we recommend using Python 3.12 + or earlier until support is confirmed. + +.. _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: +Depending on your operating system, you may be able to install beets using a +package manager. Here are some common options: -* On **Debian or Ubuntu**, depending on the version, beets is available as an +.. 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. + +- 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 @@ -63,7 +79,6 @@ Depending on your operating system, you may be able to install beets using a pac - On **FreeBSD**, there's a `beets port `_ at ``audio/beets``. - On **OpenBSD**, there's a `beets port `_ 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``. @@ -82,38 +97,31 @@ Depending on your operating system, you may be able to install beets using a pac .. _freebsd: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets -.. _macports: https://www.macports.org +.. _nixos: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets -.. _DNF package: https://packages.fedoraproject.org/pkgs/beets/ -.. _FreeBSD: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets -.. _AUR: https://aur.archlinux.org/packages/beets-git/ -.. _Debian details: https://tracker.debian.org/pkg/beets -.. _Ubuntu details: https://launchpad.net/ubuntu/+source/beets -.. _Arch extra: https://archlinux.org/packages/extra/any/beets/ -.. _Alpine package: https://pkgs.alpinelinux.org/package/edge/community/x86_64/beets -.. _NixOS: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets -.. _pip: https://pip.pypa.io/en/ -.. _pipx: https://pipx.pypa.io/stable -.. _pipx installation page: https://pipx.pypa.io/stable/installation/ +.. _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 Installing on Windows -~~~~~~~~~~~~~~~~~~~~~ ++++++++++++++++++++++ Installing beets on Windows can be tricky. Following these steps might help you get it right: 1. If you don't have it, `install Python`_ (you want at least Python 3.9). 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. - + 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:\Python39;C:\Python39\Scripts``. You may need to adjust these paths 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:\Python39;C:\Python39\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 @@ -125,8 +133,8 @@ 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". -If you have trouble or you have more detail to contribute here, please direct it to -`the discussion board`_. +If you have trouble or you have more detail to contribute here, please direct it +to `the discussion board`_. .. _beets.reg: https://github.com/beetbox/beets/blob/master/extra/beets.reg @@ -137,7 +145,7 @@ If you have trouble or you have more detail to contribute here, please direct it .. _install python: https://python.org/download/ Installing on ARM (Raspberry Pi and similar) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +++++++++++++++++++++++++++++++++++++++++++++ Beets on ARM devices is not recommended for Linux novices. If you are comfortable with light troubleshooting in tools like ``pip``, ``make``, and @@ -346,5 +354,6 @@ blog `_. Please let us know what you think of beets via `the discussion board`_ or Mastodon_. -.. _the discussion board: https://github.com/beetbox/beets/discussions .. _mastodon: https://fosstodon.org/@beets + +.. _the discussion board: https://github.com/beetbox/beets/discussions diff --git a/docs/index.rst b/docs/index.rst index 870f608c7..13e28c1c2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,10 +13,11 @@ Then you can get a more detailed look at beets' features in the be interested in exploring the :doc:`plugins `. If you still need help, you can drop by the ``#beets`` IRC channel on -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. +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/ + .. _file a bug: https://github.com/beetbox/beets/issues .. _the discussion board: https://github.com/beetbox/beets/discussions/ From 7caa68a1412fff902597fa1e6b3cc8513f640921 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 9 Sep 2025 12:25:05 +0200 Subject: [PATCH 22/83] Re-added macport instructions. Removed mailing list ref. Added section header for pip and pipx. Removed python 3.13 attention. --- docs/guides/main.rst | 26 +++++++++++++------------- docs/index.rst | 2 -- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 070ab0e2c..2ac2dafe1 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -13,17 +13,20 @@ Beets requires Python 3.9 or later, you will need to install that first. Depending on your operating system, you may also be able to install beets from a package manager, or you can install it with pipx_ or pip_. -Using pip(x) -~~~~~~~~~~~~ +Using ``pipx`` +~~~~~~~~~~~~~~ -To use the most recent version of beets, we recommend installing it with pipx_, -the Python package manager. If you don't have pipx_ installed, you can follow -the instructions on the `pipx installation page`_ to get it set up. +To use the most recent version of beets, we recommend installing it with pipx_. +If you don't have pipx_ installed, you can follow the instructions on the `pipx +installation page`_ to get it set up. .. code-block:: console pipx install beets +Using ``pip`` +~~~~~~~~~~~~~ + If you prefer to use pip_, you can install beets with the following command: .. code-block:: console @@ -32,14 +35,6 @@ If you prefer to use pip_, you can install beets with the following command: # or, to install for the current user only: pip install --user beets -.. attention:: - - Python 3.13 is not officially supported yet! - - If you are using Python 3.13, please be aware that it is not officially - supported yet. You may encounter issues, and we recommend using Python 3.12 - or earlier until support is confirmed. - .. _pip: https://pip.pypa.io/en/ .. _pipx: https://pipx.pypa.io/stable @@ -60,6 +55,9 @@ package manager. Here are some common options: 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 @@ -84,6 +82,8 @@ package manager. Here are some common options: - On **Solus**, run ``eopkg install beets``. - On **NixOS**, there's a `package `_ 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 diff --git a/docs/index.rst b/docs/index.rst index 13e28c1c2..e9dd3b34f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,8 +22,6 @@ tracker. Please let us know where you think this documentation can be improved. .. _the discussion board: https://github.com/beetbox/beets/discussions/ -.. _the mailing list: https://groups.google.com/group/beets-users - Contents -------- From 7e81f23de6be00c7f70d30afc84bec01519f9487 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 9 Sep 2025 12:52:37 +0200 Subject: [PATCH 23/83] Readded (outdated) mac instructions. No idea why they were dropped. --- docs/guides/main.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 2ac2dafe1..84b719cbf 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -105,6 +105,19 @@ package manager. Here are some common options: .. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets +Installing by Hand on macOS 10.11 and Higher +++++++++++++++++++++++++++++++++++++++++++++ + +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.) + +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. + Installing on Windows +++++++++++++++++++++ From 1270364796cf6190e048f2f3c66101aeee72b716 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 30 Sep 2025 19:21:21 +0200 Subject: [PATCH 24/83] Modernized getting started guide. --- docs/conf.py | 5 +- docs/guides/index.rst | 1 + docs/guides/installation.rst | 179 +++++++++++ docs/guides/main.rst | 565 +++++++++++++++++++---------------- poetry.lock | 45 ++- pyproject.toml | 7 +- 6 files changed, 540 insertions(+), 262 deletions(-) create mode 100644 docs/guides/installation.rst diff --git a/docs/conf.py b/docs/conf.py index c76f87524..057141d22 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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. 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. + +.. + +.. 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 + +.. + +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] `_, so just run ``pacman + -S beets``. (There's also a bleeding-edge `dev package `_ in the AUR, + which will probably set your computer on fire.) +- On **Alpine Linux**, `beets is in the community repository `_ + and can be installed with ``apk add beets``. +- On **Void Linux**, `beets is in the official repository `_ 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 `_ at ``audio/beets``. +- On **OpenBSD**, there's a `beets port `_ 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 `_ 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 84b719cbf..2b8947edb 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -1,341 +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 +------------------ -Beets requires Python 3.9 or later, you will need to install that first. -Depending on your operating system, you may also be able to install beets from a -package manager, or you can install it with pipx_ or pip_. +Beets is distributed via PyPI_ and can be installed by most users with a single +command: -Using ``pipx`` -~~~~~~~~~~~~~~ +.. include:: installation.rst + :start-after: + :end-before: -To use the most recent version of beets, we recommend installing it with pipx_. -If you don't have pipx_ installed, you can follow the instructions on the `pipx -installation page`_ to get it set up. +.. admonition:: Need more installation options? -.. code-block:: console + Having trouble with the commands above? Looking for package manager + instructions? See the :doc:`complete installation guide + ` for: - pipx install beets + - Operating system specific instructions + - Package manager options + - Troubleshooting help -Using ``pip`` -~~~~~~~~~~~~~ +.. _pypi: https://pypi.org/project/beets/ -If you prefer to use pip_, you can install beets with the following command: +Basic Configuration +------------------- -.. code-block:: console +Before using beets, you'll need a configuration file. This YAML_ file tells +beets where to store your music and how to organize it. - pip install beets - # or, to install for the current user only: - pip install --user beets +While beets is highly configurable, you only need a few basic settings to get +started. -.. _pip: https://pip.pypa.io/en/ +1. **Open the config file:** + .. code-block:: console -.. _pipx: https://pipx.pypa.io/stable + beet config -e -.. _pipx installation page: https://pipx.pypa.io/stable/installation/ + 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. -Using a Package Manager -~~~~~~~~~~~~~~~~~~~~~~~ + .. code-block:: yaml -Depending on your operating system, you may be able to install beets using a -package manager. Here are some common options: + 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: -.. attention:: + .. tab-set:: - Package manager installations may not provide the latest version of beets. + .. tab-item:: Copy Files (Default) - 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. + 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. - Additionally, installing external beets plugins may be surprisingly - difficult when using a package manager. + .. code-block:: yaml -- 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] `_, so just run ``pacman - -S beets``. (There's also a bleeding-edge `dev package `_ in the AUR, - which will probably set your computer on fire.) -- On **Alpine Linux**, `beets is in the community repository `_ - and can be installed with ``apk add beets``. -- On **Void Linux**, `beets is in the official repository `_ 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 `_ at ``audio/beets``. -- On **OpenBSD**, there's a `beets port `_ 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 `_ 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. + import: + copy: yes # Copy files to new location -.. _alpine package: https://pkgs.alpinelinux.org/package/edge/community/x86_64/beets -.. _arch extra: https://archlinux.org/packages/extra/any/beets/ + .. tab-item:: Move Files -.. _aur: https://aur.archlinux.org/packages/beets-git/ + Start with a new empty directory, but *move* new music in instead of copying it (saving disk space). -.. _debian details: https://tracker.debian.org/pkg/beets + .. code-block:: yaml -.. _dnf package: https://packages.fedoraproject.org/pkgs/beets/ + import: + move: yes # Move files to new location -.. _freebsd: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets + .. tab-item:: Use Existing Structure -.. _nixos: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets + 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. -.. _openbsd: http://openports.se/audio/beets + .. code-block:: yaml -.. _ubuntu details: https://launchpad.net/ubuntu/+source/beets + import: + copy: no # Use files in place -.. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets + .. tab-item:: Read-Only Mode -Installing by Hand on macOS 10.11 and Higher -++++++++++++++++++++++++++++++++++++++++++++ + 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.) -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.) + .. code-block:: yaml -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. + 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. -Installing on Windows -+++++++++++++++++++++ + We recommend adding at least one :ref:`Autotagger Plugin + ` to help with fetching metadata during import. + For getting started, :doc:`MusicBrainz ` is a good + choice. -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.9). 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:\Python39;C:\Python39\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. + plugins: + - musicbrainz # Example plugin for fetching metadata + - ... other plugins you want ... -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". - -If you have trouble or you have more detail to contribute here, please direct it -to `the discussion board`_. - -.. _beets.reg: https://github.com/beetbox/beets/blob/master/extra/beets.reg - -.. _get-pip.py: https://bootstrap.pypa.io/get-pip.py - -.. _install pip: https://pip.pypa.io/en/stable/installing/ - -.. _install python: https://python.org/download/ - -Installing on ARM (Raspberry Pi and similar) -++++++++++++++++++++++++++++++++++++++++++++ - -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 ` 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 + `. .. _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:: Full configuration file -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 `. -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 -A /my/huge/mp3/library + + The ``-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 `. + +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 `, -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 ` 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 ` +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 -` 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 @@ -344,29 +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 `. + 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 `. 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 `_. +.. 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 + + .. 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: https://github.com/beetbox/beets + :link-type: url + + - 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 + `_. diff --git a/poetry.lock b/poetry.lock index 8c109f930..0be47723f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3216,6 +3216,49 @@ docs = ["sphinxcontrib-websupport"] lint = ["flake8 (>=6.0)", "importlib-metadata (>=6.0)", "mypy (==1.10.1)", "pytest (>=6.0)", "ruff (==0.5.2)", "sphinx-lint (>=0.9)", "tomli (>=2)", "types-docutils (==0.21.0.20240711)", "types-requests (>=2.30.0)"] test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"] +[[package]] +name = "sphinx-copybutton" +version = "0.5.2" +description = "Add a copy button to each of your code cells." +optional = false +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 = false +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" @@ -3629,4 +3672,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "faea27878ce1ca3f1335fd83e027b289351c51c73550bda72bf501a9c82166f7" +content-hash = "caa669bfb1ff913d528553de4bc4f420279e6bc9b119d39c4db616041576266d" diff --git a/pyproject.toml b/pyproject.toml index 62b5ac25a..1babdadd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,6 +119,11 @@ click = ">=8.1.7" packaging = ">=24.0" tomli = ">=2.0.1" + +[tool.poetry.group.docs.dependencies] +sphinx-design = "^0.6.1" +sphinx-copybutton = "^0.5.2" + [tool.poetry.extras] # inline comments note required external / non-python dependencies absubmit = ["requests"] # extractor binary from https://acousticbrainz.org/download @@ -129,7 +134,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"] From 3a6769d3b9a51ef0b1241b1d54520c3015b237d1 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Wed, 1 Oct 2025 14:48:31 +0200 Subject: [PATCH 25/83] Set sphinx dependencies as optional --- poetry.lock | 8 ++++---- pyproject.toml | 7 ++----- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0be47723f..6f0523a42 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3220,7 +3220,7 @@ test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "setuptools name = "sphinx-copybutton" version = "0.5.2" description = "Add a copy button to each of your code cells." -optional = false +optional = true python-versions = ">=3.7" files = [ {file = "sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd"}, @@ -3238,7 +3238,7 @@ rtd = ["ipython", "myst-nb", "sphinx", "sphinx-book-theme", "sphinx-examples"] name = "sphinx-design" version = "0.6.1" description = "A sphinx extension for designing beautiful, view size responsive web components." -optional = false +optional = true python-versions = ">=3.9" files = [ {file = "sphinx_design-0.6.1-py3-none-any.whl", hash = "sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c"}, @@ -3650,7 +3650,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"] @@ -3672,4 +3672,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "caa669bfb1ff913d528553de4bc4f420279e6bc9b119d39c4db616041576266d" +content-hash = "1db39186aca430ef6f1fd9e51b9dcc3ed91880a458bc21b22d950ed8589fdf5a" diff --git a/pyproject.toml b/pyproject.toml index 1babdadd3..3a355418c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,6 +79,8 @@ soco = { version = "*", 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 = "*" @@ -119,11 +121,6 @@ click = ">=8.1.7" packaging = ">=24.0" tomli = ">=2.0.1" - -[tool.poetry.group.docs.dependencies] -sphinx-design = "^0.6.1" -sphinx-copybutton = "^0.5.2" - [tool.poetry.extras] # inline comments note required external / non-python dependencies absubmit = ["requests"] # extractor binary from https://acousticbrainz.org/download From 32fdad1411a671076385ebf9bd484c3cedc722b5 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Sat, 11 Oct 2025 13:55:29 +0200 Subject: [PATCH 26/83] Enhanced changelog entry. --- docs/changelog.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index fd72f4f7a..4f32093fe 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,8 +15,11 @@ For packagers: Other changes: -- Removed old mailing list contact info in docs :bug:`5462` -- :doc:`guides/main`: Modernized getting started guide using tabs and dropdown menue. Installtion instructions are now more condensed and there is a subpage for additional instructions. +- 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) ------------------------ From dd9917d3f36935631d305cc1022d6d94efafb64b Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Sat, 11 Oct 2025 14:27:44 +0200 Subject: [PATCH 27/83] Removed yaml hyperlink. Changed dropdown naming. Use full console param instead of short form. --- docs/guides/main.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 2b8947edb..b2d1aa00d 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -31,8 +31,8 @@ command: Basic Configuration ------------------- -Before using beets, you'll need a configuration file. This YAML_ file tells -beets where to store your music and how to organize it. +Before using beets, you'll need a configuration file. This YAML file tells beets +where to store your music and how to organize it. While beets is highly configurable, you only need a few basic settings to get started. @@ -121,7 +121,7 @@ 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. -.. dropdown:: Full configuration file +.. dropdown:: Minimal configuration Here's a sample configuration file that includes the settings mentioned above: @@ -189,9 +189,9 @@ There are two good ways to bring your *existing* library into beets database. .. code-block:: console - beet import -A /my/huge/mp3/library + beet import --noautotag /my/huge/mp3/library - The ``-A`` flag skips autotagging and uses your files' current metadata. + The ``--noautotag`` / ``-A`` flag skips autotagging and uses your files' current metadata. .. admonition:: More Import Options From dcec32794227bbd5a613660f579a391af13ad591 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Sat, 11 Oct 2025 14:32:35 +0200 Subject: [PATCH 28/83] Developer Resources card now links to doc page. --- docs/guides/main.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guides/main.rst b/docs/guides/main.rst index b2d1aa00d..48b248927 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -405,8 +405,8 @@ We'd love to hear about your experience with beets! - Get help from other users .. grid-item-card:: :octicon:`git-pull-request` Developer Resources - :link: https://github.com/beetbox/beets - :link-type: url + :link: /dev/index + :link-type: doc - Contribute code - Report issues From 37a5f9cb156e33690cac37f77cebd103bdbecbcf Mon Sep 17 00:00:00 2001 From: Ember Light Date: Sun, 12 Oct 2025 20:47:51 +0200 Subject: [PATCH 29/83] Add custom feat words --- beets/plugins.py | 6 ++- beetsplug/ftintitle.py | 57 +++++++++++++++++++-------- test/plugins/test_ftintitle.py | 71 +++++++++++++++++++++++++++++++++- 3 files changed, 114 insertions(+), 20 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index 7fa0e660a..397c33822 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -632,13 +632,15 @@ def send(event: EventType, **arguments: Any) -> list[Any]: ] -def feat_tokens(for_artist: bool = True) -> str: +def feat_tokens( + for_artist: bool = True, custom_feat_words: list[str] = [] +) -> 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."] + feat_words = ["ft", "featuring", "feat", "feat.", "ft."] + custom_feat_words if for_artist: feat_words += ["with", "vs", "and", "con", "&"] return ( diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index e17d7bc1c..b1331e893 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -27,7 +27,7 @@ if TYPE_CHECKING: def split_on_feat( - artist: str, for_artist: bool = True + artist: str, for_artist: bool = True, custom_feat_words: list[str] = [] ) -> 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 +35,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_feat_words), re.IGNORECASE + ) parts = tuple(s.strip() for s in regex.split(artist, 1)) if len(parts) == 1: return parts[0], None @@ -44,18 +46,22 @@ def split_on_feat( return parts -def contains_feat(title: str) -> bool: +def contains_feat(title: str, custom_feat_words: list[str] = []) -> 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_feat_words=custom_feat_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_feat_words: list[str] = [] +) -> 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 +75,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_feat_words=custom_feat_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_feat_words=custom_feat_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_feat_words) return feat_part @@ -96,6 +106,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "drop": False, "format": "feat. {}", "keep_in_artist": False, + "custom_feat_words": [], } ) @@ -120,10 +131,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_feat_words = self.config["custom_feat_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_feat_words + ): item.store() if write: item.try_write() @@ -135,9 +149,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_feat_words = self.config["custom_feat_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_feat_words + ): item.store() def update_metadata( @@ -146,6 +163,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): feat_part: str, drop_feat: bool, keep_in_artist_field: bool, + custom_feat_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 +176,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_feat_words=custom_feat_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_feat_words=custom_feat_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_feat_words): feat_format = self.config["format"].as_str() new_format = feat_format.format(feat_part) new_title = f"{item.title} {new_format}" @@ -180,6 +202,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): item: Item, drop_feat: bool, keep_in_artist_field: bool, + custom_feat_words: list[str], ) -> bool: """Look for featured artists in the item's artist fields and move them to the title. @@ -196,19 +219,21 @@ class FtInTitlePlugin(plugins.BeetsPlugin): if albumartist and artist == albumartist: return False - _, featured = split_on_feat(artist) + _, featured = split_on_feat(artist, custom_feat_words=custom_feat_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_feat_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_feat_words + ) return True diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 005318b11..466a95e4f 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_feat_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_feat_words variants ---- + pytest.param( + {"format": "featuring {}", "custom_feat_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_feat_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_feat_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_feat_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_feat_words( + given: str, custom_feat_words: Optional[list[str]], expected: bool +) -> None: + if custom_feat_words is None: + custom_feat_words = [] + assert ftintitle.contains_feat(given, custom_feat_words) is expected From 992938f0ae70631f0124642a1d07c63b7ba72973 Mon Sep 17 00:00:00 2001 From: Ember Light Date: Sun, 12 Oct 2025 20:58:38 +0200 Subject: [PATCH 30/83] Add documentation --- docs/plugins/ftintitle.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index 90b89ae89..733c50510 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_feat_word**: Add custom words to the feat list; any words you add will + also be treated as "feat" tokens. Default: ``[]``. Running Manually ---------------- From e90738a6e27d53e13964d4c7de191d56dd1e80f5 Mon Sep 17 00:00:00 2001 From: Ember Light Date: Sun, 12 Oct 2025 21:09:17 +0200 Subject: [PATCH 31/83] Added changelog --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 4f32093fe..f5c96d598 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,6 +8,7 @@ Unreleased ---------- New features: +- Added argument for custom feat. words in ftintitle. Bug fixes: From 51c971f089a0f16debccf10122869c5a278a0ed2 Mon Sep 17 00:00:00 2001 From: Ember Light Date: Sun, 12 Oct 2025 21:38:13 +0200 Subject: [PATCH 32/83] Fix sourcery-ai comments --- beets/plugins.py | 6 ++++-- beetsplug/ftintitle.py | 12 +++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index 397c33822..65c181388 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -633,14 +633,16 @@ def send(event: EventType, **arguments: Any) -> list[Any]: def feat_tokens( - for_artist: bool = True, custom_feat_words: list[str] = [] + for_artist: bool = True, custom_feat_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."] + custom_feat_words + feat_words = ["ft", "featuring", "feat", "feat.", "ft."] + if isinstance(custom_feat_words, list): + feat_words += custom_feat_words if for_artist: feat_words += ["with", "vs", "and", "con", "&"] return ( diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index b1331e893..6837732b2 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, custom_feat_words: list[str] = [] + artist: str, + for_artist: bool = True, + custom_feat_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 @@ -46,7 +48,9 @@ def split_on_feat( return parts -def contains_feat(title: str, custom_feat_words: list[str] = []) -> bool: +def contains_feat( + title: str, custom_feat_words: list[str] | None = None +) -> bool: """Determine whether the title contains a "featured" marker.""" return bool( re.search( @@ -60,7 +64,9 @@ def contains_feat(title: str, custom_feat_words: list[str] = []) -> bool: def find_feat_part( - artist: str, albumartist: str | None, custom_feat_words: list[str] = [] + artist: str, + albumartist: str | None, + custom_feat_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. From af09e58fb07ca6363b849f610f93915ebeea2801 Mon Sep 17 00:00:00 2001 From: Ember Light Date: Sun, 12 Oct 2025 21:40:22 +0200 Subject: [PATCH 33/83] Add new line after New features: --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index f5c96d598..d696f98ee 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,6 +8,7 @@ Unreleased ---------- New features: + - Added argument for custom feat. words in ftintitle. Bug fixes: From b95a17d8d35562cca9d3353d23202e0cbdba1314 Mon Sep 17 00:00:00 2001 From: Ember Light Date: Sun, 12 Oct 2025 22:40:27 +0200 Subject: [PATCH 34/83] remove feat from custom_feat_words --- beets/plugins.py | 6 ++--- beetsplug/ftintitle.py | 46 ++++++++++++++++------------------ test/plugins/test_ftintitle.py | 22 ++++++++-------- 3 files changed, 35 insertions(+), 39 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index 65c181388..b96a3703c 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -633,7 +633,7 @@ def send(event: EventType, **arguments: Any) -> list[Any]: def feat_tokens( - for_artist: bool = True, custom_feat_words: list[str] | None = None + 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. @@ -641,8 +641,8 @@ def feat_tokens( suitable for matching artist fields (the default) or title fields. """ feat_words = ["ft", "featuring", "feat", "feat.", "ft."] - if isinstance(custom_feat_words, list): - feat_words += custom_feat_words + if isinstance(custom_words, list): + feat_words += custom_words if for_artist: feat_words += ["with", "vs", "and", "con", "&"] return ( diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 6837732b2..ef9b763cf 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -29,7 +29,7 @@ if TYPE_CHECKING: def split_on_feat( artist: str, for_artist: bool = True, - custom_feat_words: list[str] | None = None, + 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 @@ -38,7 +38,7 @@ def split_on_feat( """ # split on the first "feat". regex = re.compile( - plugins.feat_tokens(for_artist, custom_feat_words), re.IGNORECASE + plugins.feat_tokens(for_artist, custom_words), re.IGNORECASE ) parts = tuple(s.strip() for s in regex.split(artist, 1)) if len(parts) == 1: @@ -48,15 +48,11 @@ def split_on_feat( return parts -def contains_feat( - title: str, custom_feat_words: list[str] | None = None -) -> 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, custom_feat_words=custom_feat_words - ), + plugins.feat_tokens(for_artist=False, custom_words=custom_words), title, flags=re.IGNORECASE, ) @@ -66,7 +62,7 @@ def contains_feat( def find_feat_part( artist: str, albumartist: str | None, - custom_feat_words: list[str] | None = 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. @@ -82,7 +78,7 @@ def find_feat_part( if albumartist_split[1] != "": # Extract the featured artist from the right-hand side. _, feat_part = split_on_feat( - albumartist_split[1], custom_feat_words=custom_feat_words + albumartist_split[1], custom_words=custom_words ) return feat_part @@ -90,7 +86,7 @@ def find_feat_part( # look for a featuring artist on the left-hand side. else: lhs, _ = split_on_feat( - albumartist_split[0], custom_feat_words=custom_feat_words + albumartist_split[0], custom_words=custom_words ) if lhs: return lhs @@ -98,7 +94,7 @@ def find_feat_part( # 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, custom_feat_words) + _, feat_part = split_on_feat(artist, False, custom_words) return feat_part @@ -112,7 +108,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "drop": False, "format": "feat. {}", "keep_in_artist": False, - "custom_feat_words": [], + "custom_words": [], } ) @@ -137,12 +133,12 @@ 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_feat_words = self.config["custom_feat_words"].get(list) + 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, custom_feat_words + item, drop_feat, keep_in_artist_field, custom_words ): item.store() if write: @@ -155,11 +151,11 @@ 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_feat_words = self.config["custom_feat_words"].get(list) + 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, custom_feat_words + item, drop_feat, keep_in_artist_field, custom_words ): item.store() @@ -169,7 +165,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): feat_part: str, drop_feat: bool, keep_in_artist_field: bool, - custom_feat_words: list[str], + 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. @@ -183,7 +179,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): ) else: track_artist, _ = split_on_feat( - item.artist, custom_feat_words=custom_feat_words + item.artist, custom_words=custom_words ) self._log.info("artist: {0.artist} -> {1}", item, track_artist) item.artist = track_artist @@ -191,12 +187,12 @@ class FtInTitlePlugin(plugins.BeetsPlugin): if item.artist_sort: # Just strip the featured artist from the sort name. item.artist_sort, _ = split_on_feat( - item.artist_sort, custom_feat_words=custom_feat_words + 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, custom_feat_words): + 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}" @@ -208,7 +204,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): item: Item, drop_feat: bool, keep_in_artist_field: bool, - custom_feat_words: list[str], + custom_words: list[str], ) -> bool: """Look for featured artists in the item's artist fields and move them to the title. @@ -225,14 +221,14 @@ class FtInTitlePlugin(plugins.BeetsPlugin): if albumartist and artist == albumartist: return False - _, featured = split_on_feat(artist, custom_feat_words=custom_feat_words) + _, 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, custom_feat_words) + feat_part = find_feat_part(artist, albumartist, custom_words) if not feat_part: self._log.info("no featuring artists found") @@ -240,6 +236,6 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # If we have a featuring artist, move it to the title. self.update_metadata( - item, feat_part, drop_feat, keep_in_artist_field, custom_feat_words + item, feat_part, drop_feat, keep_in_artist_field, custom_words ) return True diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 466a95e4f..30b414948 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -46,7 +46,7 @@ def set_config( "drop": False, "auto": True, "keep_in_artist": False, - "custom_feat_words": [], + "custom_words": [], } env.config["ftintitle"].set(defaults) env.config["ftintitle"].set(cfg) @@ -172,9 +172,9 @@ def add_item( ("Alice ft Bob", "Song 1"), id="keep-in-artist-drop-from-title", ), - # ---- custom_feat_words variants ---- + # ---- custom_words variants ---- pytest.param( - {"format": "featuring {}", "custom_feat_words": ["med"]}, + {"format": "featuring {}", "custom_words": ["med"]}, ("ftintitle",), ("Alice med Bob", "Song 1", "Alice"), ("Alice", "Song 1 featuring Bob"), @@ -184,7 +184,7 @@ def add_item( { "format": "featuring {}", "keep_in_artist": True, - "custom_feat_words": ["med"], + "custom_words": ["med"], }, ("ftintitle",), ("Alice med Bob", "Song 1", "Alice"), @@ -195,7 +195,7 @@ def add_item( { "format": "featuring {}", "keep_in_artist": True, - "custom_feat_words": ["med"], + "custom_words": ["med"], }, ( "ftintitle", @@ -294,7 +294,7 @@ def test_contains_feat(given: str, expected: bool) -> None: @pytest.mark.parametrize( - "given,custom_feat_words,expected", + "given,custom_words,expected", [ ("Alice ft. Bob", [], True), ("Alice feat. Bob", [], True), @@ -317,9 +317,9 @@ def test_contains_feat(given: str, expected: bool) -> None: ("Alice med Carol", [], False), ], ) -def test_custom_feat_words( - given: str, custom_feat_words: Optional[list[str]], expected: bool +def test_custom_words( + given: str, custom_words: Optional[list[str]], expected: bool ) -> None: - if custom_feat_words is None: - custom_feat_words = [] - assert ftintitle.contains_feat(given, custom_feat_words) is expected + if custom_words is None: + custom_words = [] + assert ftintitle.contains_feat(given, custom_words) is expected From 717809c52c198e426335ef9be2f4ea082e5c1662 Mon Sep 17 00:00:00 2001 From: Ember Light Date: Sun, 12 Oct 2025 22:40:44 +0200 Subject: [PATCH 35/83] Better custom_words documentation --- docs/plugins/ftintitle.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index 733c50510..6528b61cd 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -28,8 +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_feat_word**: Add custom words to the feat list; any words you add will - also be treated as "feat" tokens. Default: ``[]``. +- **custom_words**: List of additional words that will be treated as a marker for + artist features. Default: ``[]``. Running Manually ---------------- From 0f0e38b0bfea5f31fd3f3e4b2463d39a0555d096 Mon Sep 17 00:00:00 2001 From: Ember Light Date: Sun, 12 Oct 2025 22:40:55 +0200 Subject: [PATCH 36/83] Add link in changelog --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index d696f98ee..e6a81ab14 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,7 +9,7 @@ Unreleased New features: -- Added argument for custom feat. words in ftintitle. +- :doc:`plugins/fitintitle`: Added argument for custom feat. words in ftintitle. Bug fixes: From 33b350a612aa80e1b2ab01e4f76cee6b76af66b1 Mon Sep 17 00:00:00 2001 From: Michael Krieger Date: Mon, 15 Sep 2025 18:50:47 -0400 Subject: [PATCH 37/83] Adds a zero_disc_if_single_disc to the zero plugin Adds a zero_disc_number_if_single_disc boolean to the zero plugin for writing to files. Adds the logic that, if disctotal is set and there is only one disc in disctotal, that the disc is not set. This keeps tags cleaner, only using disc on multi-disc albums. The disctotal is not touched, particularly as this is not usually displayed in most clients. The field is removed only for writing the tags, but the disc number is maintained in the database to avoid breaking anything that may depend on a disc number or avoid possible loops or failed logic. --- beetsplug/zero.py | 7 ++++++- docs/changelog.rst | 3 +++ docs/plugins/zero.rst | 2 ++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/beetsplug/zero.py b/beetsplug/zero.py index bce3b1a72..e65dd8286 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -41,6 +41,7 @@ class ZeroPlugin(BeetsPlugin): "fields": [], "keep_fields": [], "update_database": False, + "zero_disc_if_single_disc": False, } ) @@ -123,8 +124,12 @@ class ZeroPlugin(BeetsPlugin): """ fields_set = False + if "disc" in tags and self.config["zero_disc_if_single_disc"].get(bool) and item.disctotal == 1: + self._log.debug("disc: {.disc} -> None", item) + tags["disc"] = None + if not self.fields_to_progs: - self._log.warning("no fields, nothing to do") + self._log.warning("no fields list to remove") return False for field, progs in self.fields_to_progs.items(): diff --git a/docs/changelog.rst b/docs/changelog.rst index 4f32093fe..f5109a9b4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,6 +8,9 @@ Unreleased ---------- New features: +* :doc:`plugins/zero`: Add new configuration option, + ``zero_disc_if_single_disc``, to allow zeroing the disc number on + write for single-disc albums. Defaults to False. Bug fixes: diff --git a/docs/plugins/zero.rst b/docs/plugins/zero.rst index 6ed9427d9..88903a389 100644 --- a/docs/plugins/zero.rst +++ b/docs/plugins/zero.rst @@ -31,6 +31,8 @@ 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 ``zero_disc_if_single_disc`` to ``True`` to zero the disc number field + only if the album contains a disctotal count and is a single disc. - 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. From 5fc15bcfa4814418c2256e0db1b062b571ac9268 Mon Sep 17 00:00:00 2001 From: Michael Krieger Date: Mon, 15 Sep 2025 19:00:06 -0400 Subject: [PATCH 38/83] Misc formatting changes --- beetsplug/zero.py | 7 ++++--- docs/changelog.rst | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/beetsplug/zero.py b/beetsplug/zero.py index e65dd8286..c8ef9f855 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -124,9 +124,10 @@ class ZeroPlugin(BeetsPlugin): """ fields_set = False - if "disc" in tags and self.config["zero_disc_if_single_disc"].get(bool) and item.disctotal == 1: - self._log.debug("disc: {.disc} -> None", item) - tags["disc"] = None + if "disc" in tags and self.config["zero_disc_if_single_disc"].get(bool): + if item.disctotal == 1: + self._log.debug("disc: {.disc} -> None", item) + tags["disc"] = None if not self.fields_to_progs: self._log.warning("no fields list to remove") diff --git a/docs/changelog.rst b/docs/changelog.rst index f5109a9b4..ac3af6257 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,9 +8,10 @@ Unreleased ---------- New features: -* :doc:`plugins/zero`: Add new configuration option, - ``zero_disc_if_single_disc``, to allow zeroing the disc number on - write for single-disc albums. Defaults to False. + +- :doc:`plugins/zero`: Add new configuration option, + ``zero_disc_if_single_disc``, to allow zeroing the disc number on write for + single-disc albums. Defaults to False. Bug fixes: From b1c87cd98c2f7287af40fee8560234b4d0adec4e Mon Sep 17 00:00:00 2001 From: Michael Krieger Date: Tue, 16 Sep 2025 10:04:24 -0400 Subject: [PATCH 39/83] Change parameter name, add return, add tests Change the parameter name to omit_single_disc (vs previously zero_disc_if_single_disc) Add return of 'fields_set' so that, if triggered by the command line `beets zero`, it will still effect the item.write. Added tests. --- beetsplug/zero.py | 7 +++--- docs/changelog.rst | 6 ++--- docs/plugins/zero.rst | 4 ++-- test/plugins/test_zero.py | 48 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 8 deletions(-) diff --git a/beetsplug/zero.py b/beetsplug/zero.py index c8ef9f855..c957a27d3 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -41,7 +41,7 @@ class ZeroPlugin(BeetsPlugin): "fields": [], "keep_fields": [], "update_database": False, - "zero_disc_if_single_disc": False, + "omit_single_disc": False, } ) @@ -124,14 +124,15 @@ class ZeroPlugin(BeetsPlugin): """ fields_set = False - if "disc" in tags and self.config["zero_disc_if_single_disc"].get(bool): + 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 list to remove") - return False + return fields_set for field, progs in self.fields_to_progs.items(): if field in tags: diff --git a/docs/changelog.rst b/docs/changelog.rst index ac3af6257..773c6cc67 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,9 +9,9 @@ Unreleased New features: -- :doc:`plugins/zero`: Add new configuration option, - ``zero_disc_if_single_disc``, to allow zeroing the disc number on write for - single-disc albums. Defaults to False. +- :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: diff --git a/docs/plugins/zero.rst b/docs/plugins/zero.rst index 88903a389..50b51797e 100644 --- a/docs/plugins/zero.rst +++ b/docs/plugins/zero.rst @@ -31,8 +31,8 @@ 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 ``zero_disc_if_single_disc`` to ``True`` to zero the disc number field - only if the album contains a disctotal count and is a single disc. +- Set ``omit_single_disc`` to ``True`` to zero the disc number field only if the + album contains a disctotal count and is a single disc. - 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/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" From dc133087847b676ed987a7ea8191ffe3b566af17 Mon Sep 17 00:00:00 2001 From: Michael Krieger Date: Tue, 16 Sep 2025 11:55:34 -0400 Subject: [PATCH 40/83] Remove tests. Update docs. Remove unnecessary return Remove tests. Update docs. Remove unnecessary return. --- beetsplug/zero.py | 1 - docs/plugins/zero.rst | 5 ++-- test/plugins/test_zero.py | 48 --------------------------------------- 3 files changed, 3 insertions(+), 51 deletions(-) diff --git a/beetsplug/zero.py b/beetsplug/zero.py index c957a27d3..ab1bfa5ca 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -132,7 +132,6 @@ class ZeroPlugin(BeetsPlugin): if not self.fields_to_progs: self._log.warning("no fields list to remove") - return fields_set for field, progs in self.fields_to_progs.items(): if field in tags: diff --git a/docs/plugins/zero.rst b/docs/plugins/zero.rst index 50b51797e..bf134e664 100644 --- a/docs/plugins/zero.rst +++ b/docs/plugins/zero.rst @@ -31,8 +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 zero the disc number field only if the - album contains a disctotal count and is a single disc. +- 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/test/plugins/test_zero.py b/test/plugins/test_zero.py index b08bf0dca..51913c8e0 100644 --- a/test/plugins/test_zero.py +++ b/test/plugins/test_zero.py @@ -249,54 +249,6 @@ 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" From df8cd23ae7979a82fdcf8d0b31a2dec8676dc43b Mon Sep 17 00:00:00 2001 From: Michael Krieger Date: Tue, 16 Sep 2025 11:57:50 -0400 Subject: [PATCH 41/83] Add back tests as they were. Add back tests as they were. --- test/plugins/test_zero.py | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) 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" From d01f960e4f565325d13446f16a245be2d6609a53 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Mon, 13 Oct 2025 17:10:38 +0200 Subject: [PATCH 42/83] Fixed an issue where the poetry-dynamic-versioning-plugin was not used in release artifacts. Also adds a test_release workflow which allows to create the release distribution. --- .github/workflows/test_release.yaml | 43 +++++++++++++++++++++++++++++ extra/release.py | 4 --- pyproject.toml | 5 ++-- 3 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/test_release.yaml diff --git a/.github/workflows/test_release.yaml b/.github/workflows/test_release.yaml new file mode 100644 index 000000000..fd1c79c03 --- /dev/null +++ b/.github/workflows/test_release.yaml @@ -0,0 +1,43 @@ +name: Make a Beets Release artifacts + +on: + workflow_dispatch: + inputs: + version: + description: 'Version of the new release, just as a number with no prepended "v"' + required: true + +env: + PYTHON_VERSION: 3.9 + NEW_VERSION: ${{ inputs.version }} + NEW_TAG: v${{ inputs.version }} + +jobs: + increment-version: + name: Bump version, commit and create tag + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Python tools + uses: BrandonLWhite/pipx-install-action@v1.0.3 + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: poetry + + - name: Install dependencies + run: poetry install --with=release --extras=docs + + - name: Bump project version + run: poe bump "${{ env.NEW_VERSION }}" + + - name: Build a binary wheel and a source tarball + run: poe build + + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions-test + path: dist/ diff --git a/extra/release.py b/extra/release.py index b47de8966..650c2c40b 100755 --- a/extra/release.py +++ b/extra/release.py @@ -170,10 +170,6 @@ Other changes: UpdateVersionCallable = Callable[[str, Version], str] FILENAME_AND_UPDATE_TEXT: list[tuple[Path, UpdateVersionCallable]] = [ - ( - PYPROJECT, - lambda text, new: re.sub(r"(?<=\nversion = )[^\n]+", f'"{new}"', text), - ), (CHANGELOG, update_changelog), (BASE / "docs" / "conf.py", update_docs_config), ] diff --git a/pyproject.toml b/pyproject.toml index 3a355418c..b21d0d17a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "beets" -version = "2.5.0" +version = "0.0.0" description = "music tagger and library organizer" authors = ["Adrian Sampson "] maintainers = ["Serene-Arc"] @@ -36,6 +36,7 @@ include = [ # extra files to include in the sdist ] exclude = ["docs/_build", "docs/modd.conf", "docs/**/*.css"] + [tool.poetry.urls] Changelog = "https://github.com/beetbox/beets/blob/master/docs/changelog.rst" "Bug Tracker" = "https://github.com/beetbox/beets/issues" @@ -173,7 +174,7 @@ build-backend = "poetry_dynamic_versioning.backend" [tool.pipx-install] poethepoet = ">=0.26" -poetry = ">=1.8,<2" +poetry = { version = ">=1.8,<2", inject = {"poetry-dynamic-versioning[plugin]" = ">=1.9.1" }} [tool.poe.tasks.build] help = "Build the package" From 4ea37b4579f245f5d1d591615f7a91656442764c Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Mon, 13 Oct 2025 17:18:00 +0200 Subject: [PATCH 43/83] Added changelog entry fixed action to use sha. --- .github/workflows/test_release.yaml | 4 ++-- docs/changelog.rst | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test_release.yaml b/.github/workflows/test_release.yaml index fd1c79c03..af960021d 100644 --- a/.github/workflows/test_release.yaml +++ b/.github/workflows/test_release.yaml @@ -1,4 +1,4 @@ -name: Make a Beets Release artifacts +name: Create a beets release artifact (testing only) on: workflow_dispatch: @@ -21,7 +21,7 @@ jobs: with: fetch-depth: 0 - name: Install Python tools - uses: BrandonLWhite/pipx-install-action@v1.0.3 + uses: BrandonLWhite/pipx-install-action@1b697df89b675eb31d19417e53b4c066d21650d7 # v1.1.0 - uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} diff --git a/docs/changelog.rst b/docs/changelog.rst index 773c6cc67..5f4afe58d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -17,6 +17,9 @@ Bug fixes: For packagers: +- Fixed dynamic versioning install not disabled for source distribution builds. + :bug:`6089` + Other changes: - Removed outdated mailing list contact information from the documentation From ac31bee4ca6fd8e3f1da0981772a6038e6b1130d Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 14 Oct 2025 10:21:47 +0200 Subject: [PATCH 44/83] Reverted placeholder. --- extra/release.py | 4 ++++ pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/extra/release.py b/extra/release.py index 650c2c40b..b47de8966 100755 --- a/extra/release.py +++ b/extra/release.py @@ -170,6 +170,10 @@ Other changes: UpdateVersionCallable = Callable[[str, Version], str] FILENAME_AND_UPDATE_TEXT: list[tuple[Path, UpdateVersionCallable]] = [ + ( + PYPROJECT, + lambda text, new: re.sub(r"(?<=\nversion = )[^\n]+", f'"{new}"', text), + ), (CHANGELOG, update_changelog), (BASE / "docs" / "conf.py", update_docs_config), ] diff --git a/pyproject.toml b/pyproject.toml index b21d0d17a..b6c846a54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "beets" -version = "0.0.0" +version = "2.5.0" description = "music tagger and library organizer" authors = ["Adrian Sampson "] maintainers = ["Serene-Arc"] From 7f15a4608137ccb5ad7e0fcfe25210454736def8 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 14 Oct 2025 10:34:08 +0200 Subject: [PATCH 45/83] Added perms to flow. --- .github/workflows/test_release.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test_release.yaml b/.github/workflows/test_release.yaml index af960021d..9efde5e64 100644 --- a/.github/workflows/test_release.yaml +++ b/.github/workflows/test_release.yaml @@ -1,4 +1,6 @@ name: Create a beets release artifact (testing only) +permissions: + contents: write on: workflow_dispatch: From febb1d2e08c819c03fb914e713fb7ff8155e1eb2 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 14 Oct 2025 11:43:27 +0200 Subject: [PATCH 46/83] Removed test release file. --- .github/workflows/test_release.yaml | 45 ----------------------------- 1 file changed, 45 deletions(-) delete mode 100644 .github/workflows/test_release.yaml diff --git a/.github/workflows/test_release.yaml b/.github/workflows/test_release.yaml deleted file mode 100644 index 9efde5e64..000000000 --- a/.github/workflows/test_release.yaml +++ /dev/null @@ -1,45 +0,0 @@ -name: Create a beets release artifact (testing only) -permissions: - contents: write - -on: - workflow_dispatch: - inputs: - version: - description: 'Version of the new release, just as a number with no prepended "v"' - required: true - -env: - PYTHON_VERSION: 3.9 - NEW_VERSION: ${{ inputs.version }} - NEW_TAG: v${{ inputs.version }} - -jobs: - increment-version: - name: Bump version, commit and create tag - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Install Python tools - uses: BrandonLWhite/pipx-install-action@1b697df89b675eb31d19417e53b4c066d21650d7 # v1.1.0 - - uses: actions/setup-python@v5 - with: - python-version: ${{ env.PYTHON_VERSION }} - cache: poetry - - - name: Install dependencies - run: poetry install --with=release --extras=docs - - - name: Bump project version - run: poe bump "${{ env.NEW_VERSION }}" - - - name: Build a binary wheel and a source tarball - run: poe build - - - name: Store the distribution packages - uses: actions/upload-artifact@v4 - with: - name: python-package-distributions-test - path: dist/ From 31488e79dae45edd67467512d7b1a4935000c0ca Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 14 Oct 2025 12:47:27 +0200 Subject: [PATCH 47/83] Removed additional linebreaks. --- pyproject.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b6c846a54..dd67fa65c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,6 @@ include = [ # extra files to include in the sdist ] exclude = ["docs/_build", "docs/modd.conf", "docs/**/*.css"] - [tool.poetry.urls] Changelog = "https://github.com/beetbox/beets/blob/master/docs/changelog.rst" "Bug Tracker" = "https://github.com/beetbox/beets/issues" @@ -159,7 +158,6 @@ web = ["flask", "flask-cors"] [tool.poetry.scripts] beet = "beets.ui:main" - [tool.poetry-dynamic-versioning] enable = true vcs = "git" From 320ebf6a205041dd5062082c631c01026342cc17 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Tue, 14 Oct 2025 14:07:45 +0200 Subject: [PATCH 48/83] Fix misspelling --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e6a81ab14..d4d90ebe5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,7 +9,7 @@ Unreleased New features: -- :doc:`plugins/fitintitle`: Added argument for custom feat. words in ftintitle. +- :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle. Bug fixes: From 83858cd7ca0ed7f0ed3f464a0262ad48d76377da Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Tue, 14 Oct 2025 14:08:30 +0200 Subject: [PATCH 49/83] Fixed too long text line --- docs/plugins/ftintitle.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index 6528b61cd..1a95d03a8 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -28,8 +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: ``[]``. +- **custom_words**: List of additional words that will be treated as a marker + for artist features. Default: ``[]``. Running Manually ---------------- From 75a945d3d3e83e6c0d4276c510f266582b7d7ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 14 Oct 2025 15:14:55 +0100 Subject: [PATCH 50/83] Initialise the last plugin class found in the plugin namespace --- beets/plugins.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/beets/plugins.py b/beets/plugins.py index 7fa0e660a..b866081ff 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -422,6 +422,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: @@ -429,7 +435,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( From 7fa9a30b896d6fc65d574a80667e61b41d8e4385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 14 Oct 2025 16:17:29 +0100 Subject: [PATCH 51/83] Add note regarding the last plugin class --- docs/changelog.rst | 8 ++++---- docs/conf.py | 1 + docs/dev/plugins/autotagger.rst | 6 +++--- docs/dev/plugins/index.rst | 10 ++++++++-- docs/reference/config.rst | 8 ++++---- 5 files changed, 20 insertions(+), 13 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5f4afe58d..bdf9babba 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -66,7 +66,7 @@ Bug fixes: - :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from artists but not labels. :bug:`5366` - :doc:`plugins/chroma` :doc:`plugins/bpsync` Fix plugin loading issue caused by - an import of another :class:`beets.plugins.BeetsPlugin` class. :bug:`6033` + an import of another |BeetsPlugin| class. :bug:`6033` - :doc:`/plugins/fromfilename`: Fix :bug:`5218`, improve the code (refactor regexps, allow for more cases, add some logging), add tests. - Metadata source plugins: Fixed data source penalty calculation that was @@ -188,8 +188,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 @@ -5072,7 +5072,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 057141d22..a027b3005 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -82,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/reference/config.rst b/docs/reference/config.rst index 30582d12c..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: :: From 13f40de5bb1b7ac775d23ca3144b06c8949cced9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 14 Oct 2025 16:21:33 +0100 Subject: [PATCH 52/83] Make _verify_config method private to remove it from the docs --- beets/plugins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index b866081ff..a8e803efd 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -228,9 +228,9 @@ class BeetsPlugin(metaclass=abc.ABCMeta): # 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) + self.register_listener("pluginload", self._verify_config) - def verify_config(self, *_, **__) -> None: + def _verify_config(self, *_, **__) -> None: """Verify plugin configuration. If deprecated 'source_weight' option is explicitly set by the user, they From fbc12a358c8dff4c449f6a59861c20208fd868fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 14 Oct 2025 16:35:53 +0100 Subject: [PATCH 53/83] Add changelog note --- docs/changelog.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index bdf9babba..6d08d6bdb 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,6 +15,9 @@ New features: Bug fixes: +- |BeetsPlugin|: load the last plugin class defined in the plugin namespace. + :bug:`6093` + For packagers: - Fixed dynamic versioning install not disabled for source distribution builds. @@ -23,7 +26,7 @@ For packagers: Other changes: - Removed outdated mailing list contact information from the documentation - (:bug:`5462`). + :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. From f33c030ebb9a20f4e94f4d6a1dbd9c1eb205baf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 14 Oct 2025 16:53:57 +0100 Subject: [PATCH 54/83] Convert replacements and Include URLs for :class: refs in release notes --- extra/release.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/extra/release.py b/extra/release.py index b47de8966..afa762baf 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 From 670c300625b0dd7ef7a5c62c3ae2106ed5dfbf15 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 14 Oct 2025 17:15:02 +0200 Subject: [PATCH 55/83] Fixed issue with legacy plugin copy not copying properties. Also added test for it --- beets/plugins.py | 25 +++++++++++++++---------- docs/changelog.rst | 2 ++ test/test_plugins.py | 20 ++++++++++++++++++++ 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index a8e803efd..9c7a93b7f 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -22,7 +22,7 @@ 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 @@ -192,15 +192,20 @@ class BeetsPlugin(metaclass=abc.ABCMeta): stacklevel=3, ) - for name, method in inspect.getmembers( - MetadataSourcePlugin, - predicate=lambda f: ( - inspect.isfunction(f) - and f.__name__ not in MetadataSourcePlugin.__abstractmethods__ - and not hasattr(cls, f.__name__) - ), - ): - setattr(cls, name, method) + abstracts = MetadataSourcePlugin.__abstractmethods__ + + for name, method in inspect.getmembers(MetadataSourcePlugin): + # Skip if already defined in the subclass + if hasattr(cls, name) or name in abstracts: + continue + + # Copy functions, methods, and properties + if ( + inspect.isfunction(method) + or inspect.ismethod(method) + or isinstance(method, cached_property) + ): + setattr(cls, name, method) def __init__(self, name: str | None = None): """Perform one-time plugin setup.""" diff --git a/docs/changelog.rst b/docs/changelog.rst index 6d08d6bdb..88c157ec5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,6 +22,8 @@ For packagers: - Fixed dynamic versioning install not disabled for source distribution builds. :bug:`6089` +- Fixed issue with legacy metadata plugins not copying properties from the base + class. Other changes: 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") From f339d8a4d381e5bac50cf6bb3e69cdf126d1e495 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 14 Oct 2025 17:40:03 +0200 Subject: [PATCH 56/83] slight simplification. --- beets/plugins.py | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index 9c7a93b7f..5e7ac6f96 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -192,20 +192,29 @@ class BeetsPlugin(metaclass=abc.ABCMeta): stacklevel=3, ) - abstracts = MetadataSourcePlugin.__abstractmethods__ - - for name, method in inspect.getmembers(MetadataSourcePlugin): - # Skip if already defined in the subclass - if hasattr(cls, name) or name in abstracts: - continue - - # Copy functions, methods, and properties - if ( - inspect.isfunction(method) - or inspect.ismethod(method) - or isinstance(method, cached_property) - ): - setattr(cls, name, method) + for name, method in inspect.getmembers( + MetadataSourcePlugin, + predicate=lambda f: ( + ( + isinstance(f, cached_property) + and f.attrname is not None + and not hasattr(BeetsPlugin, f.attrname) + ) + or ( + isinstance(f, property) + and f.fget is not None + and f.fget.__name__ is not None + and not hasattr(BeetsPlugin, f.fget.__name__) + ) + or ( + inspect.isfunction(f) + and f.__name__ + not in MetadataSourcePlugin.__abstractmethods__ + and not hasattr(BeetsPlugin, f.__name__) + ) + ), + ): + setattr(cls, name, method) def __init__(self, name: str | None = None): """Perform one-time plugin setup.""" From 365ff6b0303804cd1408c6f2e250eb22f5ad8b0c Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 14 Oct 2025 18:50:52 +0200 Subject: [PATCH 57/83] Added test additions --- test/autotag/test_distance.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/autotag/test_distance.py b/test/autotag/test_distance.py index b327bbe44..213d32956 100644 --- a/test/autotag/test_distance.py +++ b/test/autotag/test_distance.py @@ -11,6 +11,7 @@ from beets.autotag.distance import ( ) 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 @@ -310,8 +311,13 @@ class TestDataSourceDistance: def candidates(self, *args, **kwargs): ... def item_candidates(self, *args, **kwargs): ... - class OriginalPlugin(TestMetadataSourcePlugin): - pass + # 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 @@ -332,6 +338,7 @@ class TestDataSourceDistance: [ _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 From 391ca4ca26fe5a7946bdcbfecec4f02e740d6393 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 14 Oct 2025 19:45:56 +0200 Subject: [PATCH 58/83] Yet some more simplification. --- beets/plugins.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index 5e7ac6f96..678d653b4 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -192,24 +192,21 @@ class BeetsPlugin(metaclass=abc.ABCMeta): stacklevel=3, ) + method: property | cached_property[Any] | Callable[..., Any] for name, method in inspect.getmembers( MetadataSourcePlugin, - predicate=lambda f: ( + predicate=lambda f: ( # type: ignore[arg-type] ( - isinstance(f, cached_property) - and f.attrname is not None - and not hasattr(BeetsPlugin, f.attrname) - ) - or ( - isinstance(f, property) - and f.fget is not None - and f.fget.__name__ is not None - and not hasattr(BeetsPlugin, f.fget.__name__) + 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__ - not in MetadataSourcePlugin.__abstractmethods__ + and not getattr(f, "__isabstractmethod__", False) and not hasattr(BeetsPlugin, f.__name__) ) ), From efe1a67e849c7fff673dd7a9248bf196811ec2b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 14 Oct 2025 23:38:01 +0100 Subject: [PATCH 59/83] Revert "Fix dynamic versioning plugin not correctly installed in workflow (#6094)" This reverts commit dc9b498ee89fa6286f5eb741a068eedccda637d6, reversing changes made to 77842b72d73ec46dcee0b9d44dc3eeef145fc59f. --- docs/changelog.rst | 2 -- pyproject.toml | 3 ++- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 88c157ec5..8f28e8d1c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -20,8 +20,6 @@ Bug fixes: For packagers: -- Fixed dynamic versioning install not disabled for source distribution builds. - :bug:`6089` - Fixed issue with legacy metadata plugins not copying properties from the base class. diff --git a/pyproject.toml b/pyproject.toml index dd67fa65c..3a355418c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -158,6 +158,7 @@ web = ["flask", "flask-cors"] [tool.poetry.scripts] beet = "beets.ui:main" + [tool.poetry-dynamic-versioning] enable = true vcs = "git" @@ -172,7 +173,7 @@ build-backend = "poetry_dynamic_versioning.backend" [tool.pipx-install] poethepoet = ">=0.26" -poetry = { version = ">=1.8,<2", inject = {"poetry-dynamic-versioning[plugin]" = ">=1.9.1" }} +poetry = ">=1.8,<2" [tool.poe.tasks.build] help = "Build the package" From 61cbc39c4aa18fd2a2f81e55934b51b4ebf8e752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 14 Oct 2025 23:39:27 +0100 Subject: [PATCH 60/83] Revert "Add git commit suffix to __version__ for development installs (#5967)" --- .gitignore | 3 --- beets/__init__.py | 6 +----- beets/_version.py | 7 ------- docs/changelog.rst | 3 +++ extra/release.py | 6 ++++++ pyproject.toml | 13 ++----------- 6 files changed, 12 insertions(+), 26 deletions(-) delete mode 100644 beets/_version.py 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..bdb19b579 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.0" __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/docs/changelog.rst b/docs/changelog.rst index 8f28e8d1c..a76e69bb9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,6 +22,9 @@ 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: diff --git a/extra/release.py b/extra/release.py index afa762baf..d4ebb950f 100755 --- a/extra/release.py +++ b/extra/release.py @@ -186,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/pyproject.toml b/pyproject.toml index 3a355418c..2570330c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -158,18 +158,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" From c1877b7cf5371f5399fbf80f5e9e64bd119b9917 Mon Sep 17 00:00:00 2001 From: snejus Date: Tue, 14 Oct 2025 22:51:15 +0000 Subject: [PATCH 61/83] Increment version to 2.5.1 --- beets/__init__.py | 2 +- docs/changelog.rst | 11 +++++++++++ docs/conf.py | 2 +- pyproject.toml | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/beets/__init__.py b/beets/__init__.py index bdb19b579..d448d8c49 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -19,7 +19,7 @@ import confuse from .util import deprecate_imports -__version__ = "2.5.0" +__version__ = "2.5.1" __author__ = "Adrian Sampson " diff --git a/docs/changelog.rst b/docs/changelog.rst index a76e69bb9..a90c1920b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,17 @@ Unreleased New features: +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. diff --git a/docs/conf.py b/docs/conf.py index a027b3005..c2cecc510 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,7 +14,7 @@ copyright = "2016, Adrian Sampson" master_doc = "index" language = "en" version = "2.5" -release = "2.5.0" +release = "2.5.1" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/pyproject.toml b/pyproject.toml index 2570330c6..0058c7f9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "beets" -version = "2.5.0" +version = "2.5.1" description = "music tagger and library organizer" authors = ["Adrian Sampson "] maintainers = ["Serene-Arc"] From 8613b3573c14d8bdf082c218f6aeb456307b5d57 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 14 Sep 2025 09:03:32 +0200 Subject: [PATCH 62/83] lastgenre: Refactor final genre apply - Move item and genre apply to separate helper functions. Have one function for each to not overcomplicate implementation! - Use a decorator log_and_pretend that logs and does the right thing depending on wheter --pretend was passed or not. - Sets --force (internally) automatically if --pretend is given (this is a behavirol change needing discussion) --- beetsplug/lastgenre/__init__.py | 102 ++++++++++++++++++-------------- 1 file changed, 57 insertions(+), 45 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 1da5ecde4..301c94377 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -24,6 +24,7 @@ https://gist.github.com/1241307 import os import traceback +from functools import wraps from pathlib import Path from typing import Union @@ -76,6 +77,28 @@ def find_parents(candidate, branches): return [candidate] +def log_and_pretend(apply_func): + """Decorator that logs genre assignments and conditionally applies changes + based on pretend mode.""" + + @wraps(apply_func) + def wrapper(self, obj, label, genre): + obj_type = type(obj).__name__.lower() + attr_name = "album" if obj_type == "album" else "title" + msg = ( + f'genre for {obj_type} "{getattr(obj, attr_name)}" ' + f"({label}): {genre}" + ) + if self.config["pretend"]: + self._log.info(f"Pretend: {msg}") + return None + + self._log.info(msg) + return apply_func(self, obj, label, genre) + + return wrapper + + # Main plugin logic. WHITELIST = os.path.join(os.path.dirname(__file__), "genres.txt") @@ -101,6 +124,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): "prefer_specific": False, "title_case": True, "extended_debug": False, + "pretend": False, } ) self.setup() @@ -459,6 +483,21 @@ class LastGenrePlugin(plugins.BeetsPlugin): # Beets plugin hooks and CLI. + @log_and_pretend + def _apply_album_genre(self, obj, label, genre): + """Apply genre to an Album object, with logging and pretend mode support.""" + obj.genre = genre + if "track" in self.sources: + obj.store(inherit=False) + else: + obj.store() + + @log_and_pretend + def _apply_item_genre(self, obj, label, genre): + """Apply genre to an Item object, with logging and pretend mode support.""" + obj.genre = genre + obj.store() + def commands(self): lastgenre_cmd = ui.Subcommand("lastgenre", help="fetch genres") lastgenre_cmd.parser.add_option( @@ -527,64 +566,37 @@ class LastGenrePlugin(plugins.BeetsPlugin): def lastgenre_func(lib, opts, args): write = ui.should_write() - pretend = getattr(opts, "pretend", False) self.config.set_args(opts) + if opts.pretend: + self.config["force"].set(True) 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() + album_genre, label = self._get_genre(album) + self._apply_album_genre(album, label, album_genre) 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() + item_genre, label = self._get_genre(item) + + if not item_genre: + self._log.info( + 'No genre found for track "{0.title}"', + item, + ) + else: + self._apply_item_genre(item, label, item_genre) + if write: + item.try_write() - if write and not pretend: - item.try_write() else: - # Just query singletons, i.e. items that are not part of - # an album + # Just query single tracks or singletons 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() + singleton_genre, label = self._get_genre(item) + self._apply_item_genre(item, label, singleton_genre) lastgenre_cmd.func = lastgenre_func return [lastgenre_cmd] From 1acec39525ba9a91a652974ecc1ce1ff8e5986c8 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Wed, 17 Sep 2025 07:16:57 +0200 Subject: [PATCH 63/83] lastgenre: Use apply methods during import --- beetsplug/lastgenre/__init__.py | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 301c94377..b67a4476f 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -605,34 +605,21 @@ class LastGenrePlugin(plugins.BeetsPlugin): """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 - ) + album_genre, label = self._get_genre(album) + self._apply_album_genre(album, label, album_genre) - # If we're using track-level sources, store the album genre only, - # then also look up individual track genres. + # If we're using track-level sources, store the album genre only (this + # happened in _apply_album_genre already), 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() + item_genre, label = self._get_genre(item) + self._apply_item_genre(item, label, item_genre) 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() + item_genre, label = self._get_genre(item) + self._apply_item_genre(item, label, item_genre) def _tags_for(self, obj, min_weight=None): """Core genre identification routine. From d617e6719919d98db79bee426b718630598f7f10 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 21 Sep 2025 08:07:49 +0200 Subject: [PATCH 64/83] lastgenre: Fix test_pretend_option only one arg is passed to the info log anymore. --- test/plugins/test_lastgenre.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index d6df42f97..c3d4984d7 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -158,7 +158,8 @@ class LastGenrePluginTest(BeetsTestCase): mock_get_genre.assert_called_once() assert any( - call.args[1] == "Pretend: " for call in log_info.call_args_list + call.args[0].startswith("Pretend:") + for call in log_info.call_args_list ) # Verify that try_write was never called (file operations skipped) From 654c14490e1b3f00ca1a49bc110b90563f2bf7bc Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Thu, 25 Sep 2025 07:22:59 +0200 Subject: [PATCH 65/83] lastgenre: Refactor test_pretend to pytest --- test/plugins/test_lastgenre.py | 86 +++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 39 deletions(-) diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index c3d4984d7..91dd7c282 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -14,7 +14,7 @@ """Tests for the 'lastgenre' plugin.""" -from unittest.mock import Mock, patch +from unittest.mock import Mock import pytest @@ -131,44 +131,6 @@ class LastGenrePluginTest(BeetsTestCase): "math rock", ] - def test_pretend_option_skips_library_updates(self): - item = self.create_item( - album="Pretend Album", - albumartist="Pretend Artist", - artist="Pretend Artist", - title="Pretend Track", - genre="Original Genre", - ) - 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[0].startswith("Pretend:") - for call in log_info.call_args_list - ) - - # Verify that try_write was never called (file operations skipped) - mock_try_write.assert_not_called() - - stored_album = self.lib.get_album(album.id) - assert stored_album.genre == "Original Genre" - assert stored_album.items()[0].genre == "Original Genre" - def test_no_duplicate(self): """Remove duplicated genres.""" self._setup_config(count=99) @@ -210,6 +172,52 @@ class LastGenrePluginTest(BeetsTestCase): assert res == ["ambient", "electronic"] +def test_pretend_option_skips_library_updates(mocker): + """Test that pretend mode logs actions but skips library updates.""" + + # Setup + test_case = BeetsTestCase() + test_case.setUp() + plugin = lastgenre.LastGenrePlugin() + item = test_case.create_item( + album="Album", + albumartist="Artist", + artist="Artist", + title="Track", + genre="Original Genre", + ) + album = test_case.lib.add_album([item]) + command = plugin.commands()[0] + opts, args = command.parser.parse_args(["--pretend"]) + + # Mocks + mocker.patch.object(lastgenre.ui, "should_write", return_value=True) + mock_get_genre = mocker.patch.object( + plugin, "_get_genre", return_value=("New Genre", "log label") + ) + mock_log = mocker.patch.object(plugin._log, "info") + mock_write = mocker.patch.object(item, "try_write") + + # Run lastgenre + command.func(test_case.lib, opts, args) + mock_get_genre.assert_called_once() + + # Test logging + assert any( + call.args[0].startswith("Pretend:") for call in mock_log.call_args_list + ) + + # Test file operations should be skipped + mock_write.assert_not_called() + + # Test database should remain unchanged + stored_album = test_case.lib.get_album(album.id) + assert stored_album.genre == "Original Genre" + assert stored_album.items()[0].genre == "Original Genre" + + test_case.tearDown() + + @pytest.mark.parametrize( "config_values, item_genre, mock_genres, expected_result", [ From 65f5dd579b08395559e0a55db412d8774e47c7fa Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Thu, 25 Sep 2025 10:18:59 +0200 Subject: [PATCH 66/83] Add pytest-mock to poetry test dependencies group --- poetry.lock | 19 ++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 6f0523a42..a8196cb1e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2453,6 +2453,23 @@ Werkzeug = "*" [package.extras] docs = ["Sphinx", "sphinx-rtd-theme"] +[[package]] +name = "pytest-mock" +version = "3.15.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}, + {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -3672,4 +3689,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "1db39186aca430ef6f1fd9e51b9dcc3ed91880a458bc21b22d950ed8589fdf5a" +content-hash = "8ed50b90e399bace64062c38f784f9c7bcab2c2b7c0728cfe0a9ee78ea1fd902" diff --git a/pyproject.toml b/pyproject.toml index 0058c7f9b..5eb82f6c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,6 +100,7 @@ rarfile = "*" requests-mock = ">=1.12.1" requests_oauthlib = "*" responses = ">=0.3.0" +pytest-mock = "^3.15.1" [tool.poetry.group.lint.dependencies] docstrfmt = ">=1.11.1" From c2d5c1f17c7416763b412099ee5ccd9b32102e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 14 Oct 2025 03:28:46 +0100 Subject: [PATCH 67/83] Update test --- poetry.lock | 19 +------- pyproject.toml | 1 - test/plugins/test_lastgenre.py | 84 ++++++++++++++-------------------- 3 files changed, 36 insertions(+), 68 deletions(-) diff --git a/poetry.lock b/poetry.lock index a8196cb1e..6f0523a42 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2453,23 +2453,6 @@ Werkzeug = "*" [package.extras] docs = ["Sphinx", "sphinx-rtd-theme"] -[[package]] -name = "pytest-mock" -version = "3.15.1" -description = "Thin-wrapper around the mock package for easier use with pytest" -optional = false -python-versions = ">=3.9" -files = [ - {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}, - {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}, -] - -[package.dependencies] -pytest = ">=6.2.5" - -[package.extras] -dev = ["pre-commit", "pytest-asyncio", "tox"] - [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -3689,4 +3672,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "8ed50b90e399bace64062c38f784f9c7bcab2c2b7c0728cfe0a9ee78ea1fd902" +content-hash = "1db39186aca430ef6f1fd9e51b9dcc3ed91880a458bc21b22d950ed8589fdf5a" diff --git a/pyproject.toml b/pyproject.toml index 5eb82f6c7..0058c7f9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,6 @@ rarfile = "*" requests-mock = ">=1.12.1" requests_oauthlib = "*" responses = ">=0.3.0" -pytest-mock = "^3.15.1" [tool.poetry.group.lint.dependencies] docstrfmt = ">=1.11.1" diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 91dd7c282..151f122a6 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -14,16 +14,18 @@ """Tests for the 'lastgenre' plugin.""" -from unittest.mock import Mock +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,36 @@ 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", + albumartist="Pretend Artist", + artist="Pretend Artist", + title="Pretend Track", + genre="Original Genre", + ) + album = self.lib.add_album([item]) + + def unexpected_store(*_, **__): + raise AssertionError("Unexpected store call") + + # Verify that try_write was never called (file operations skipped) + with ( + patch("beetsplug.lastgenre.Item.store", unexpected_store), + self.assertLogs() as logs, + ): + self.run_command("lastgenre", "--pretend") + + assert "Mock Genre" in str(logs.output) + album.load() + assert album.genre == "Original Genre" + assert album.items()[0].genre == "Original Genre" + def test_no_duplicate(self): """Remove duplicated genres.""" self._setup_config(count=99) @@ -172,52 +204,6 @@ class LastGenrePluginTest(BeetsTestCase): assert res == ["ambient", "electronic"] -def test_pretend_option_skips_library_updates(mocker): - """Test that pretend mode logs actions but skips library updates.""" - - # Setup - test_case = BeetsTestCase() - test_case.setUp() - plugin = lastgenre.LastGenrePlugin() - item = test_case.create_item( - album="Album", - albumartist="Artist", - artist="Artist", - title="Track", - genre="Original Genre", - ) - album = test_case.lib.add_album([item]) - command = plugin.commands()[0] - opts, args = command.parser.parse_args(["--pretend"]) - - # Mocks - mocker.patch.object(lastgenre.ui, "should_write", return_value=True) - mock_get_genre = mocker.patch.object( - plugin, "_get_genre", return_value=("New Genre", "log label") - ) - mock_log = mocker.patch.object(plugin._log, "info") - mock_write = mocker.patch.object(item, "try_write") - - # Run lastgenre - command.func(test_case.lib, opts, args) - mock_get_genre.assert_called_once() - - # Test logging - assert any( - call.args[0].startswith("Pretend:") for call in mock_log.call_args_list - ) - - # Test file operations should be skipped - mock_write.assert_not_called() - - # Test database should remain unchanged - stored_album = test_case.lib.get_album(album.id) - assert stored_album.genre == "Original Genre" - assert stored_album.items()[0].genre == "Original Genre" - - test_case.tearDown() - - @pytest.mark.parametrize( "config_values, item_genre, mock_genres, expected_result", [ From ee289844ede14abd1dc4f3476e9d7d425efceee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 14 Oct 2025 03:34:11 +0100 Subject: [PATCH 68/83] Add _process_album and _process_item methods --- beetsplug/lastgenre/__init__.py | 61 ++++++++++++++------------------- 1 file changed, 25 insertions(+), 36 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index b67a4476f..80374b962 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -498,6 +498,27 @@ class LastGenrePlugin(plugins.BeetsPlugin): obj.genre = genre obj.store() + def _process_item(self, item: Item, write: bool = False): + genre, label = self._get_genre(item) + + if genre: + self._apply_item_genre(item, label, genre) + if write and not self.config["pretend"]: + item.try_write() + else: + self._log.info('No genre found for track "{.title}"', item) + + def _process_album(self, album: Album, write: bool = False): + album_genre, label = self._get_genre(album) + self._apply_album_genre(album, label, album_genre) + + # If we're using track-level sources, store the album genre only (this + # happened in _apply_album_genre already), then also look up individual + # track genres. + if "track" in self.sources: + for item in album.items(): + self._process_item(item, write=write) + def commands(self): lastgenre_cmd = ui.Subcommand("lastgenre", help="fetch genres") lastgenre_cmd.parser.add_option( @@ -573,30 +594,11 @@ class LastGenrePlugin(plugins.BeetsPlugin): if opts.album: # Fetch genres for whole albums for album in lib.albums(args): - album_genre, label = self._get_genre(album) - self._apply_album_genre(album, label, album_genre) - - 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, label = self._get_genre(item) - - if not item_genre: - self._log.info( - 'No genre found for track "{0.title}"', - item, - ) - else: - self._apply_item_genre(item, label, item_genre) - if write: - item.try_write() - + self._process_album(album, write=write) else: # Just query single tracks or singletons for item in lib.items(args): - singleton_genre, label = self._get_genre(item) - self._apply_item_genre(item, label, singleton_genre) + self._process_item(item, write=write) lastgenre_cmd.func = lastgenre_func return [lastgenre_cmd] @@ -604,22 +606,9 @@ class LastGenrePlugin(plugins.BeetsPlugin): def imported(self, session, task): """Event hook called when an import task finishes.""" if task.is_album: - album = task.album - album_genre, label = self._get_genre(album) - self._apply_album_genre(album, label, album_genre) - - # If we're using track-level sources, store the album genre only (this - # happened in _apply_album_genre already), then also look up individual - # track genres. - if "track" in self.sources: - for item in album.items(): - item_genre, label = self._get_genre(item) - self._apply_item_genre(item, label, item_genre) - + self._process_album(task.album) else: - item = task.item - item_genre, label = self._get_genre(item) - self._apply_item_genre(item, label, item_genre) + self._process_item(task.item) def _tags_for(self, obj, min_weight=None): """Core genre identification routine. From 0aac7315c3056b6292dc00140093b41a464ce0a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 15 Oct 2025 02:54:24 +0100 Subject: [PATCH 69/83] lastgenre: refactor genre processing with singledispatch Replace the log_and_pretend decorator with a more robust implementation using singledispatchmethod. This simplifies the genre application logic by consolidating logging and processing into dedicated methods. Key changes: - Remove log_and_pretend decorator in favor of explicit dispatch - Add _fetch_and_log_genre method to centralize genre fetching and logging - Log user-configured full object representation instead of specific attributes - Introduce _process singledispatchmethod with type-specific handlers - Use LibModel type hint for broader compatibility - Simplify command handler by removing duplicate album/item logic - Replace manual genre application with try_sync for consistency --- beetsplug/lastgenre/__init__.py | 118 +++++++++++--------------------- 1 file changed, 41 insertions(+), 77 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 80374b962..8bd33ff5d 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -22,11 +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 wraps +from functools import singledispatchmethod from pathlib import Path -from typing import Union +from typing import TYPE_CHECKING, Union import pylast import yaml @@ -35,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 = ( @@ -77,28 +82,6 @@ def find_parents(candidate, branches): return [candidate] -def log_and_pretend(apply_func): - """Decorator that logs genre assignments and conditionally applies changes - based on pretend mode.""" - - @wraps(apply_func) - def wrapper(self, obj, label, genre): - obj_type = type(obj).__name__.lower() - attr_name = "album" if obj_type == "album" else "title" - msg = ( - f'genre for {obj_type} "{getattr(obj, attr_name)}" ' - f"({label}): {genre}" - ) - if self.config["pretend"]: - self._log.info(f"Pretend: {msg}") - return None - - self._log.info(msg) - return apply_func(self, obj, label, genre) - - return wrapper - - # Main plugin logic. WHITELIST = os.path.join(os.path.dirname(__file__), "genres.txt") @@ -345,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() @@ -366,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 @@ -483,41 +464,36 @@ class LastGenrePlugin(plugins.BeetsPlugin): # Beets plugin hooks and CLI. - @log_and_pretend - def _apply_album_genre(self, obj, label, genre): - """Apply genre to an Album object, with logging and pretend mode support.""" - obj.genre = genre + 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.info("Resolved ({}): {}", label, obj.genre) + + @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: - obj.store(inherit=False) - else: - obj.store() + for item in obj.items(): + self._process(item, write) - @log_and_pretend - def _apply_item_genre(self, obj, label, genre): - """Apply genre to an Item object, with logging and pretend mode support.""" - obj.genre = genre - obj.store() - - def _process_item(self, item: Item, write: bool = False): - genre, label = self._get_genre(item) - - if genre: - self._apply_item_genre(item, label, genre) - if write and not self.config["pretend"]: - item.try_write() - else: - self._log.info('No genre found for track "{.title}"', item) - - def _process_album(self, album: Album, write: bool = False): - album_genre, label = self._get_genre(album) - self._apply_album_genre(album, label, album_genre) - - # If we're using track-level sources, store the album genre only (this - # happened in _apply_album_genre already), then also look up individual - # track genres. - if "track" in self.sources: - for item in album.items(): - self._process_item(item, write=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") @@ -586,29 +562,17 @@ class LastGenrePlugin(plugins.BeetsPlugin): lastgenre_cmd.parser.set_defaults(album=True) def lastgenre_func(lib, opts, args): - write = ui.should_write() self.config.set_args(opts) - if opts.pretend: - self.config["force"].set(True) - if opts.album: - # Fetch genres for whole albums - for album in lib.albums(args): - self._process_album(album, write=write) - else: - # Just query single tracks or singletons - for item in lib.items(args): - self._process_item(item, write=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: - self._process_album(task.album) - else: - self._process_item(task.item) + 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. From 88011a7c659c70d471e31b5e432c74dccb1f182a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 15 Oct 2025 11:14:26 +0100 Subject: [PATCH 70/83] Show genre change using show_model_changes --- beets/ui/__init__.py | 6 ++++-- beetsplug/lastgenre/__init__.py | 4 +++- test/plugins/test_lastgenre.py | 9 +++------ 3 files changed, 10 insertions(+), 9 deletions(-) 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/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 8bd33ff5d..902cef9ef 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -468,7 +468,9 @@ class LastGenrePlugin(plugins.BeetsPlugin): """Fetch genre and log it.""" self._log.info(str(obj)) obj.genre, label = self._get_genre(obj) - self._log.info("Resolved ({}): {}", label, obj.genre) + 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: diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 151f122a6..12ff30f8e 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -152,13 +152,10 @@ class LastGenrePluginTest(PluginTestCase): raise AssertionError("Unexpected store call") # Verify that try_write was never called (file operations skipped) - with ( - patch("beetsplug.lastgenre.Item.store", unexpected_store), - self.assertLogs() as logs, - ): - self.run_command("lastgenre", "--pretend") + with patch("beetsplug.lastgenre.Item.store", unexpected_store): + output = self.run_with_output("lastgenre", "--pretend") - assert "Mock Genre" in str(logs.output) + assert "Mock Genre" in output album.load() assert album.genre == "Original Genre" assert album.items()[0].genre == "Original Genre" From a938449b2922acf2747e3587321bf1108fe00512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 12 Oct 2025 00:17:19 +0100 Subject: [PATCH 71/83] Add Sphinx extension for configuration value documentation Create a custom Sphinx extension to document configuration values with a simplified syntax. It is based on the `confval` but takes less space when rendered. The extension provides: - A `conf` directive for documenting individual configuration values with optional type and default parameters - A `conf` role for cross-referencing configuration values - Automatic formatting of default values in the signature - A custom domain that handles indexing and cross-references For example, if we have .. conf:: search_limit :default: 5 We refer to this configuration option with :conf:`plugins.discogs:search_limit`. The extension is loaded by adding the docs/extensions directory to the Python path and registering it in the Sphinx extensions list. --- docs/conf.py | 6 ++ docs/extensions/conf.py | 142 ++++++++++++++++++++++++++++++++++++++++ poetry.lock | 15 ++++- pyproject.toml | 15 ++++- 4 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 docs/extensions/conf.py diff --git a/docs/conf.py b/docs/conf.py index c2cecc510..8d2bae130 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,6 +6,11 @@ # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import sys +from pathlib import Path + +# Add custom extensions directory to path +sys.path.insert(0, str(Path(__file__).parent / "extensions")) project = "beets" AUTHOR = "Adrian Sampson" @@ -26,6 +31,7 @@ extensions = [ "sphinx.ext.viewcode", "sphinx_design", "sphinx_copybutton", + "conf", ] autosummary_generate = True diff --git a/docs/extensions/conf.py b/docs/extensions/conf.py new file mode 100644 index 000000000..308d28be2 --- /dev/null +++ b/docs/extensions/conf.py @@ -0,0 +1,142 @@ +"""Sphinx extension for simple configuration value documentation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +from docutils import nodes +from docutils.parsers.rst import directives +from sphinx import addnodes +from sphinx.directives import ObjectDescription +from sphinx.domains import Domain, ObjType +from sphinx.roles import XRefRole +from sphinx.util.nodes import make_refnode + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + from docutils.nodes import Element + from docutils.parsers.rst.states import Inliner + from sphinx.addnodes import desc_signature, pending_xref + from sphinx.application import Sphinx + from sphinx.builders import Builder + from sphinx.environment import BuildEnvironment + from sphinx.util.typing import ExtensionMetadata, OptionSpec + + +class Conf(ObjectDescription[str]): + """Directive for documenting a single configuration value.""" + + option_spec: ClassVar[OptionSpec] = { + "default": directives.unchanged, + } + + def handle_signature(self, sig: str, signode: desc_signature) -> str: + """Process the directive signature (the config name).""" + signode += addnodes.desc_name(sig, sig) + + # Add default value if provided + if "default" in self.options: + signode += nodes.Text(" ") + default_container = nodes.inline("", "") + default_container += nodes.Text("(default: ") + default_container += nodes.literal("", self.options["default"]) + default_container += nodes.Text(")") + signode += default_container + + return sig + + def add_target_and_index( + self, name: str, sig: str, signode: desc_signature + ) -> None: + """Add cross-reference target and index entry.""" + target = f"conf-{name}" + if target not in self.state.document.ids: + signode["ids"].append(target) + self.state.document.note_explicit_target(signode) + + # A unique full name which includes the document name + index_name = f"{self.env.docname.replace('/', '.')}:{name}" + # Register with the conf domain + domain = self.env.get_domain("conf") + domain.data["objects"][index_name] = (self.env.docname, target) + + # Add to index + self.indexnode["entries"].append( + ("single", f"{name} (configuration value)", target, "", None) + ) + + +class ConfDomain(Domain): + """Domain for simple configuration values.""" + + name = "conf" + label = "Simple Configuration" + object_types = {"conf": ObjType("conf", "conf")} + directives = {"conf": Conf} + roles = {"conf": XRefRole()} + initial_data: dict[str, Any] = {"objects": {}} + + def get_objects(self) -> Iterable[tuple[str, str, str, str, str, int]]: + """Return an iterable of object tuples for the inventory.""" + for name, (docname, targetname) in self.data["objects"].items(): + # Remove the document name prefix for display + display_name = name.split(":")[-1] + yield (name, display_name, "conf", docname, targetname, 1) + + def resolve_xref( + self, + env: BuildEnvironment, + fromdocname: str, + builder: Builder, + typ: str, + target: str, + node: pending_xref, + contnode: Element, + ) -> Element | None: + if entry := self.data["objects"].get(target): + docname, targetid = entry + return make_refnode( + builder, fromdocname, docname, targetid, contnode + ) + + return None + + +# sphinx.util.typing.RoleFunction +def conf_role( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: Inliner, + /, + options: dict[str, Any] | None = None, + content: Sequence[str] = (), +) -> tuple[list[nodes.Node], list[nodes.system_message]]: + """Role for referencing configuration values.""" + node = addnodes.pending_xref( + "", + refdomain="conf", + reftype="conf", + reftarget=text, + refwarn=True, + **(options or {}), + ) + node += nodes.literal(text, text.split(":")[-1]) + return [node], [] + + +def setup(app: Sphinx) -> ExtensionMetadata: + app.add_domain(ConfDomain) + + # register a top-level directive so users can use ".. conf:: ..." + app.add_directive("conf", Conf) + + # Register role with short name + app.add_role("conf", conf_role) + return { + "version": "0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/poetry.lock b/poetry.lock index 6f0523a42..615598d67 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3473,6 +3473,17 @@ files = [ [package.dependencies] types-html5lib = "*" +[[package]] +name = "types-docutils" +version = "0.22.2.20251006" +description = "Typing stubs for docutils" +optional = false +python-versions = ">=3.9" +files = [ + {file = "types_docutils-0.22.2.20251006-py3-none-any.whl", hash = "sha256:1e61afdeb4fab4ae802034deea3e853ced5c9b5e1d156179000cb68c85daf384"}, + {file = "types_docutils-0.22.2.20251006.tar.gz", hash = "sha256:c36c0459106eda39e908e9147bcff9dbd88535975cde399433c428a517b9e3b2"}, +] + [[package]] name = "types-flask-cors" version = "6.0.0.20250520" @@ -3650,7 +3661,7 @@ beatport = ["requests-oauthlib"] bpd = ["PyGObject"] chroma = ["pyacoustid"] discogs = ["python3-discogs-client"] -docs = ["pydata-sphinx-theme", "sphinx", "sphinx-copybutton", "sphinx-design"] +docs = ["docutils", "pydata-sphinx-theme", "sphinx", "sphinx-copybutton", "sphinx-design"] embedart = ["Pillow"] embyupdate = ["requests"] fetchart = ["Pillow", "beautifulsoup4", "langdetect", "requests"] @@ -3672,4 +3683,4 @@ web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "1db39186aca430ef6f1fd9e51b9dcc3ed91880a458bc21b22d950ed8589fdf5a" +content-hash = "aedfeb1ac78ae0120855c6a7d6f35963c63cc50a8750142c95dd07ffd213683f" diff --git a/pyproject.toml b/pyproject.toml index 0058c7f9b..b546b4dc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,10 +77,11 @@ resampy = { version = ">=0.4.3", optional = true } requests-oauthlib = { version = ">=0.6.1", optional = true } soco = { version = "*", optional = true } +docutils = { version = ">=0.20.1", optional = true } pydata-sphinx-theme = { version = "*", optional = true } sphinx = { version = "*", optional = true } -sphinx-design = { version = "^0.6.1", optional = true } -sphinx-copybutton = { version = "^0.5.2", optional = true } +sphinx-design = { version = ">=0.6.1", optional = true } +sphinx-copybutton = { version = ">=0.5.2", optional = true } [tool.poetry.group.test.dependencies] beautifulsoup4 = "*" @@ -109,6 +110,7 @@ sphinx-lint = ">=1.0.0" [tool.poetry.group.typing.dependencies] mypy = "*" types-beautifulsoup4 = "*" +types-docutils = ">=0.22.2.20251006" types-mock = "*" types-Flask-Cors = "*" types-Pillow = "*" @@ -131,7 +133,14 @@ beatport = ["requests-oauthlib"] bpd = ["PyGObject"] # gobject-introspection, gstreamer1.0-plugins-base, python3-gst-1.0 chroma = ["pyacoustid"] # chromaprint or fpcalc # convert # ffmpeg -docs = ["pydata-sphinx-theme", "sphinx", "sphinx-lint", "sphinx-design", "sphinx-copybutton"] +docs = [ + "docutils", + "pydata-sphinx-theme", + "sphinx", + "sphinx-lint", + "sphinx-design", + "sphinx-copybutton", +] discogs = ["python3-discogs-client"] embedart = ["Pillow"] # ImageMagick embyupdate = ["requests"] From 498b14ee1d50edfead49efe190335b0fc6ffd496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 12 Oct 2025 00:19:08 +0100 Subject: [PATCH 72/83] Convert autotagger plugin docs to use conf role --- beetsplug/discogs.py | 2 +- docs/plugins/deezer.rst | 20 +- docs/plugins/discogs.rst | 132 +++++++----- docs/plugins/index.rst | 60 +----- docs/plugins/musicbrainz.rst | 201 ++++++++---------- .../plugins/shared_metadata_source_config.rst | 65 ++++++ docs/plugins/spotify.rst | 100 +++++---- 7 files changed, 305 insertions(+), 275 deletions(-) create mode 100644 docs/plugins/shared_metadata_source_config.rst diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 874eab6ec..be1cf97fa 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -132,9 +132,9 @@ class DiscogsPlugin(MetadataSourcePlugin): "user_token": "", "separator": ", ", "index_tracks": False, - "featured_string": "Feat.", "append_style_genre": False, "strip_disambiguation": True, + "featured_string": "Feat.", "anv": { "artist_credit": True, "artist": False, diff --git a/docs/plugins/deezer.rst b/docs/plugins/deezer.rst index 96ed34652..d44a565ce 100644 --- a/docs/plugins/deezer.rst +++ b/docs/plugins/deezer.rst @@ -35,15 +35,23 @@ Default .. code-block:: yaml deezer: + search_query_ascii: no 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 - 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``. +.. conf:: search_query_ascii + :default: no + + If enabled, the search query will be converted to ASCII before being sent to + Deezer. Converting searches to ASCII can enhance search results in some cases, + but in general, it is not recommended. For instance, ``artist:deadmau5 + album:4×4`` will be converted to ``artist:deadmau5 album:4x4`` (notice + ``×!=x``). + +.. include:: ./shared_metadata_source_config.rst + +Commands +-------- The ``deezer`` plugin provides an additional command ``deezerupdate`` to update the ``rank`` information from Deezer. The ``rank`` (ranges from 0 to 1M) is a diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index 64b68248d..780042026 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -71,67 +71,93 @@ Default .. code-block:: yaml discogs: - data_source_mismatch_penalty: 0.5 - search_limit: 5 apikey: REDACTED apisecret: REDACTED tokenfile: discogs_token.json - user_token: REDACTED + user_token: index_tracks: no append_style_genre: no separator: ', ' strip_disambiguation: yes - -- **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: - - .. code-block:: text - - Messiah, Part I: No.1: Sinfony - Messiah, Part II: No.22: Chorus- Behold The Lamb Of God - Athalia, Act I, Scene I: Sinfonia - - whereas with ``index_tracks`` disabled you'd get: - - .. code-block:: text - - No.1: Sinfony - No.22: Chorus- Behold The Lamb Of God - Sinfonia - - This option is useful when importing classical music. - -- **append_style_genre**: Appends the Discogs style (if found) to the genre tag. - This can be useful if you want more granular genres to categorize your music. - For example, a release in Discogs might have a genre of "Electronic" and a - style of "Techno": enabling this setting would set the genre to be - "Electronic, Techno" (assuming default separator of ``", "``) instead of just - "Electronic". Default: ``False`` -- **separator**: How to join multiple genre and style values from Discogs into a - string. Default: ``", "`` -- **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: + featured_string: Feat. anv: - artist_credit: True - artist: False - album_artist: False + artist_credit: yes + artist: no + album_artist: no + data_source_mismatch_penalty: 0.5 + search_limit: 5 + +.. conf:: index_tracks + :default: no + + Index tracks (see the `Discogs guidelines`_) along with headers, mark divisions + between distinct works on the same release or within works. When enabled, + beets will incorporate the names of the divisions containing each track into the + imported track's title. + + For example, importing `divisions album`_ would result in track names like: + + .. code-block:: text + + Messiah, Part I: No.1: Sinfony + Messiah, Part II: No.22: Chorus- Behold The Lamb Of God + Athalia, Act I, Scene I: Sinfonia + + whereas with ``index_tracks`` disabled you'd get: + + .. code-block:: text + + No.1: Sinfony + No.22: Chorus- Behold The Lamb Of God + Sinfonia + + This option is useful when importing classical music. + +.. conf:: append_style_genre + :default: no + + Appends the Discogs style (if found) to the genre tag. This can be useful if + you want more granular genres to categorize your music. For example, + a release in Discogs might have a genre of "Electronic" and a style of + "Techno": enabling this setting would set the genre to be "Electronic, + Techno" (assuming default separator of ``", "``) instead of just + "Electronic". + +.. conf:: separator + :default: ", " + + How to join multiple genre and style values from Discogs into a string. + +.. conf:: strip_disambiguation + :default: yes + + Discogs uses strings like ``"(4)"`` to mark distinct artists and labels with + the same name. If you'd like to use the Discogs disambiguation in your tags, + you can disable this option. + +.. conf:: featured_string + :default: Feat. + + Configure the string used for noting featured artists. Useful if you prefer ``Featuring`` or ``ft.``. + +.. conf:: anv + + This configuration option is dedicated to handling Artist Name + Variations (ANVs). Sometimes a release credits artists differently compared to + the majority of their work. For example, "Basement Jaxx" may be credited as + "Tha Jaxx" or "The Basement Jaxx". You can select any combination of these + config options to control where beets writes and stores the variation credit. + The default, shown below, writes variations to the artist_credit field. + + .. code-block:: yaml + + discogs: + anv: + artist_credit: yes + artist: no + album_artist: no + +.. include:: ./shared_metadata_source_config.rst .. _discogs guidelines: https://support.discogs.com/hc/en-us/articles/360005055373-Database-Guidelines-12-Tracklisting#Index_Tracks_And_Headings diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index a877d2320..2c9d94dfd 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -50,65 +50,7 @@ Using Metadata Source Plugins We provide several :ref:`autotagger_extensions` that fetch metadata from online databases. They share the following configuration options: -.. _data_source_mismatch_penalty: - -- **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``. - - This setting controls how much to penalize matches from different metadata - sources during import. The penalty is applied when beets detects that a match - candidate comes from a different data source than what appears to be the - original source of your music collection. - - **Example configurations:** - - .. code-block:: yaml - - # Prefer MusicBrainz over Discogs when sources don't match - plugins: musicbrainz discogs - - musicbrainz: - data_source_mismatch_penalty: 0.3 # Lower penalty = preferred - discogs: - data_source_mismatch_penalty: 0.8 # Higher penalty = less preferred - - .. code-block:: yaml - - # Do not penalise candidates from Discogs at all - plugins: musicbrainz discogs - - musicbrainz: - data_source_mismatch_penalty: 0.5 - discogs: - data_source_mismatch_penalty: 0.0 - - .. code-block:: yaml - - # Disable cross-source penalties entirely - plugins: musicbrainz discogs - - musicbrainz: - data_source_mismatch_penalty: 0.0 - discogs: - data_source_mismatch_penalty: 0.0 - - .. tip:: - - The last configuration is equivalent to setting: - - .. code-block:: yaml - - match: - distance_weights: - data_source: 0.0 # Disable data source matching - -- **source_weight** - - .. deprecated:: 2.5 Use `data_source_mismatch_penalty`_ instead. - -- **search_limit**: Maximum number of search results to consider. Default: - ``5``. +.. include:: ./shared_metadata_source_config.rst .. toctree:: :hidden: diff --git a/docs/plugins/musicbrainz.rst b/docs/plugins/musicbrainz.rst index 5ac287368..00c553d8b 100644 --- a/docs/plugins/musicbrainz.rst +++ b/docs/plugins/musicbrainz.rst @@ -26,8 +26,6 @@ Default .. code-block:: yaml musicbrainz: - data_source_mismatch_penalty: 0.5 - search_limit: 5 host: musicbrainz.org https: no ratelimit: 1 @@ -41,122 +39,107 @@ Default deezer: no beatport: no tidal: no + data_source_mismatch_penalty: 0.5 + search_limit: 5 -You can instruct beets to use `your own MusicBrainz database -`__ instead of the +.. conf:: host + :default: musicbrainz.org -`main server`_. Use the ``host``, ``https`` and ``ratelimit`` options under a -``musicbrainz:`` header, like so + The Web server hostname (and port, optionally) that will be contacted by beets. + You can use this to configure beets to use `your own MusicBrainz database + `__ instead of the + `main server`_. -.. code-block:: yaml + The server must have search indices enabled (see `Building search indexes`_). - musicbrainz: - host: localhost:5000 - https: no - ratelimit: 100 + Example: -The ``host`` key, of course, controls the Web server hostname (and port, -optionally) that will be contacted by beets (default: musicbrainz.org). The -``https`` key makes the client use HTTPS instead of HTTP. This setting applies -only to custom servers. The official MusicBrainz server always uses HTTPS. -(Default: no.) The server must have search indices enabled (see `Building search -indexes`_). + .. code-block:: yaml -The ``ratelimit`` option, an integer, controls the number of Web service -requests per second (default: 1). **Do not change the rate limit setting** if -you're using the main MusicBrainz server---on this public server, you're -limited_ to one request per second. + musicbrainz: + host: localhost:5000 + +.. conf:: https + :default: no + + Makes the client use HTTPS instead of HTTP. This setting applies only to custom + servers. The official MusicBrainz server always uses HTTPS. + +.. conf:: ratelimit + :default: 1 + + Controls the number of Web service requests per second. + + **Do not change the rate limit setting** if you're using the main MusicBrainz + server---on this public server, you're limited_ to one request per second. + +.. conf:: ratelimit_interval + :default: 1.0 + + The time interval (in seconds) for the rate limit. + +.. conf:: enabled + :default: yes + + .. deprecated:: 2.4 Add ``musicbrainz`` to the ``plugins`` list instead. + +.. conf:: extra_tags + :default: [] + + By default, beets will use only the artist, album, and track count to query + MusicBrainz. Additional tags to be queried can be supplied with the + ``extra_tags`` setting. + + This setting should improve the autotagger results if the metadata with the + given tags match the metadata returned by MusicBrainz. + + Note that the only tags supported by this setting are: ``barcode``, + ``catalognum``, ``country``, ``label``, ``media``, and ``year``. + + Example: + + .. code-block:: yaml + + musicbrainz: + extra_tags: [barcode, catalognum, country, label, media, year] + +.. conf:: genres + :default: no + + Use MusicBrainz genre tags to populate (and replace if it's already set) the + ``genre`` tag. This will make it a list of all the genres tagged for the release + and the release-group on MusicBrainz, separated by "; " and sorted by the total + number of votes. + +.. conf:: external_ids + + **Default** + + .. code-block:: yaml + + musicbrainz: + external_ids: + discogs: no + spotify: no + bandcamp: no + beatport: no + deezer: no + tidal: no + + Set any of the ``external_ids`` options to ``yes`` to enable the MusicBrainz + importer to look for links to related metadata sources. If such a link is + available the release ID will be extracted from the URL provided and imported to + the beets library. + + The library fields of the corresponding :ref:`autotagger_extensions` are used to + save the data as flexible attributes (``discogs_album_id``, ``bandcamp_album_id``, ``spotify_album_id``, + ``beatport_album_id``, ``deezer_album_id``, ``tidal_album_id``). On re-imports + existing data will be overwritten. + +.. include:: ./shared_metadata_source_config.rst .. _building search indexes: https://musicbrainz.org/doc/Development/Search_server_setup .. _limited: https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting .. _main server: https://musicbrainz.org/ - -.. _musicbrainz.enabled: - -enabled -+++++++ - -.. deprecated:: 2.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 -make the import process quicker. - -Default: ``yes``. - -.. _search_limit: - -search_limit -++++++++++++ - -The number of matches returned when sending search queries to the MusicBrainz -server. - -Default: ``5``. - -searchlimit -+++++++++++ - -.. deprecated:: 2.4 Use `search_limit`_. - -.. _extra_tags: - -extra_tags -++++++++++ - -By default, beets will use only the artist, album, and track count to query -MusicBrainz. Additional tags to be queried can be supplied with the -``extra_tags`` setting. For example - -.. code-block:: yaml - - musicbrainz: - extra_tags: [barcode, catalognum, country, label, media, year] - -This setting should improve the autotagger results if the metadata with the -given tags match the metadata returned by MusicBrainz. - -Note that the only tags supported by this setting are the ones listed in the -above example. - -Default: ``[]`` - -.. _genres: - -genres -++++++ - -Use MusicBrainz genre tags to populate (and replace if it's already set) the -``genre`` tag. This will make it a list of all the genres tagged for the release -and the release-group on MusicBrainz, separated by "; " and sorted by the total -number of votes. Default: ``no`` - -.. _musicbrainz.external_ids: - -external_ids -++++++++++++ - -Set any of the ``external_ids`` options to ``yes`` to enable the MusicBrainz -importer to look for links to related metadata sources. If such a link is -available the release ID will be extracted from the URL provided and imported to -the beets library - -.. code-block:: yaml - - musicbrainz: - external_ids: - discogs: yes - spotify: yes - bandcamp: yes - beatport: yes - deezer: yes - tidal: yes - -The library fields of the corresponding :ref:`autotagger_extensions` are used to -save the data (``discogs_albumid``, ``bandcamp_album_id``, ``spotify_album_id``, -``beatport_album_id``, ``deezer_album_id``, ``tidal_album_id``). On re-imports -existing data will be overwritten. - -The default of all options is ``no``. diff --git a/docs/plugins/shared_metadata_source_config.rst b/docs/plugins/shared_metadata_source_config.rst new file mode 100644 index 000000000..609c7afd2 --- /dev/null +++ b/docs/plugins/shared_metadata_source_config.rst @@ -0,0 +1,65 @@ +.. _data_source_mismatch_penalty: + +.. conf:: data_source_mismatch_penalty + :default: 0.5 + + Penalty applied when the data source of a + match candidate differs from the original source of your existing tracks. Any + decimal number between 0.0 and 1.0 + + This setting controls how much to penalize matches from different metadata + sources during import. The penalty is applied when beets detects that a match + candidate comes from a different data source than what appears to be the + original source of your music collection. + + **Example configurations:** + + .. code-block:: yaml + + # Prefer MusicBrainz over Discogs when sources don't match + plugins: musicbrainz discogs + + musicbrainz: + data_source_mismatch_penalty: 0.3 # Lower penalty = preferred + discogs: + data_source_mismatch_penalty: 0.8 # Higher penalty = less preferred + + .. code-block:: yaml + + # Do not penalise candidates from Discogs at all + plugins: musicbrainz discogs + + musicbrainz: + data_source_mismatch_penalty: 0.5 + discogs: + data_source_mismatch_penalty: 0.0 + + .. code-block:: yaml + + # Disable cross-source penalties entirely + plugins: musicbrainz discogs + + musicbrainz: + data_source_mismatch_penalty: 0.0 + discogs: + data_source_mismatch_penalty: 0.0 + + .. tip:: + + The last configuration is equivalent to setting: + + .. code-block:: yaml + + match: + distance_weights: + data_source: 0.0 # Disable data source matching + +.. conf:: source_weight + :default: 0.5 + + .. deprecated:: 2.5 Use `data_source_mismatch_penalty`_ instead. + +.. conf:: search_limit + :default: 5 + + Maximum number of search results to return. diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index b72f22f20..f0d6ac2ef 100644 --- a/docs/plugins/spotify.rst +++ b/docs/plugins/spotify.rst @@ -73,8 +73,6 @@ Default .. code-block:: yaml spotify: - data_source_mismatch_penalty: 0.5 - search_limit: 5 mode: list region_filter: show_failures: no @@ -84,59 +82,67 @@ Default client_id: REDACTED client_secret: REDACTED tokenfile: spotify_token.json + data_source_mismatch_penalty: 0.5 + search_limit: 5 -- **mode**: One of the following: +.. conf:: mode + :default: list - - ``list``: Print out the playlist as a list of links. This list can then - be pasted in to a new or existing Spotify playlist. - - ``open``: This mode actually sends a link to your default browser with - instructions to open Spotify with the playlist you created. Until this - has been tested on all platforms, it will remain optional. + Controls how the playlist is output: - Default: ``list``. + - ``list``: Print out the playlist as a list of links. This list can then + be pasted in to a new or existing Spotify playlist. + - ``open``: This mode actually sends a link to your default browser with + instructions to open Spotify with the playlist you created. Until this + has been tested on all platforms, it will remain optional. -- **region_filter**: A two-character country abbreviation, to limit results to - that market. Default: None. -- **show_failures**: List each lookup that does not return a Spotify ID (and - therefore cannot be added to a playlist). Default: ``no``. -- **tiebreak**: How to choose the track if there is more than one identical - result. For example, there might be multiple releases of the same album. The - options are ``popularity`` and ``first`` (to just choose the first match - returned). Default: ``popularity``. -- **regex**: An array of regex transformations to perform on the - track/album/artist fields before sending them to Spotify. Can be useful for - changing certain abbreviations, like ft. -> feat. See the examples below. - Default: None. -- **search_query_ascii**: If set to ``yes``, the search query will be converted - to ASCII before being sent to Spotify. Converting searches to ASCII can - enhance search results in some cases, but in general, it is not recommended. - For instance ``artist:deadmau5 album:4×4`` will be converted to - ``artist:deadmau5 album:4x4`` (notice ``×!=x``). Default: ``no``. +.. conf:: region_filter + :default: -Here's an example: + A two-character country abbreviation, to limit results to that market. -:: +.. conf:: show_failures + :default: no - spotify: - data_source_mismatch_penalty: 0.7 - mode: open - region_filter: US - show_failures: on - tiebreak: first - search_query_ascii: no + List each lookup that does not return a Spotify ID (and therefore cannot be + added to a playlist). - regex: [ - { - field: "albumartist", # Field in the item object to regex. - search: "Something", # String to look for. - replace: "Replaced" # Replacement value. - }, - { - field: "title", - search: "Something Else", - replace: "AlsoReplaced" - } - ] +.. conf:: tiebreak + :default: popularity + + How to choose the candidate if there is more than one identical result. For + example, there might be multiple releases of the same album. + + - ``popularity``: pick the more popular candidate + - ``first``: pick the first candidate + +.. conf:: regex + :default: [] + + An array of regex transformations to perform on the track/album/artist fields + before sending them to Spotify. Can be useful for changing certain + abbreviations, like ft. -> feat. For example: + + .. code-block:: yaml + + regex: + - field: albumartist + search: Something + replace: Replaced + - field: title + search: Something Else + replace: AlsoReplaced + +.. conf:: search_query_ascii + :default: no + + If enabled, the search query will be converted to ASCII before being sent to + Spotify. Converting searches to ASCII can enhance search results in some + cases, but in general, it is not recommended. For instance, + ``artist:deadmau5 album:4×4`` will be converted to ``artist:deadmau5 + album:4x4`` (notice ``×!=x``). + +.. include:: ./shared_metadata_source_config.rst Obtaining Track Popularity and Audio Features from Spotify ---------------------------------------------------------- From e87235117037deb3d958172940f95d6e955d8f3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 12 Oct 2025 00:20:46 +0100 Subject: [PATCH 73/83] Add references to configuration values in the changelog --- docs/changelog.rst | 82 +++++++++++++++++++++++++--------------------- 1 file changed, 45 insertions(+), 37 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9a8fc539b..669f1eb50 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -56,12 +56,13 @@ New features: without storing or writing them. - :doc:`plugins/convert`: Add a config option to disable writing metadata to converted files. -- :doc:`plugins/discogs`: New config option `strip_disambiguation` to toggle - stripping discogs numeric disambiguation on artist and label fields. +- :doc:`plugins/discogs`: New config option + :conf:`plugins.discogs:strip_disambiguation` to toggle stripping discogs + numeric disambiguation on artist and label fields. - :doc:`plugins/discogs` Added support for featured artists. :bug:`6038` -- :doc:`plugins/discogs` New configuration option `featured_string` to change - the default string used to join featured artists. The default string is - `Feat.`. +- :doc:`plugins/discogs` New configuration option + :conf:`plugins.discogs:featured_string` to change the default string used to + join featured artists. The default string is `Feat.`. - :doc:`plugins/discogs` Support for `artist_credit` in Discogs tags. :bug:`3354` - :doc:`plugins/discogs` Support for name variations and config options to @@ -89,9 +90,10 @@ Bug fixes: - :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` + incorrectly applied during import matching. The + :conf:`plugins.index:source_weight` configuration option has been renamed to + :conf:`plugins.index:data_source_mismatch_penalty` to better reflect its + purpose. :bug:`6066` Other changes: @@ -137,12 +139,13 @@ New features: separate plugin. The default :ref:`plugins-config` includes ``musicbrainz``, but if you've customized your ``plugins`` list in your configuration, you'll need to explicitly add ``musicbrainz`` to continue using this functionality. - Configuration option ``musicbrainz.enabled`` has thus been deprecated. - :bug:`2686` :bug:`4605` + Configuration option :conf:`plugins.musicbrainz:enabled` has thus been + deprecated. :bug:`2686` :bug:`4605` - :doc:`plugins/web`: Show notifications when a track plays. This uses the Media Session API to customize media notifications. -- :doc:`plugins/discogs`: Add configurable ``search_limit`` option to limit the - number of results returned by the Discogs metadata search queries. +- :doc:`plugins/discogs`: Add configurable :conf:`plugins.discogs:search_limit` + option to limit the number of results returned by the Discogs metadata search + queries. - :doc:`plugins/discogs`: Implement ``track_for_id`` method to allow retrieving singletons by their Discogs ID. :bug:`4661` - :doc:`plugins/replace`: Add new plugin. @@ -157,12 +160,13 @@ New features: be played for it to be counted as played instead of skipped. - :doc:`plugins/web`: Display artist and album as part of the search results. - :doc:`plugins/spotify` :doc:`plugins/deezer`: Add new configuration option - ``search_limit`` to limit the number of results returned by search queries. + :conf:`plugins.index:search_limit` to limit the number of results returned by + search queries. Bug fixes: - :doc:`plugins/musicbrainz`: fix regression where user configured - ``extra_tags`` have been read incorrectly. :bug:`5788` + :conf:`plugins.musicbrainz:extra_tags` have been read incorrectly. :bug:`5788` - tests: Fix library tests failing on Windows when run from outside ``D:/``. :bug:`5802` - Fix an issue where calling ``Library.add`` would cause the ``database_change`` @@ -194,9 +198,10 @@ Bug fixes: For packagers: -- Optional ``extra_tags`` parameter has been removed from - ``BeetsPlugin.candidates`` method signature since it is never passed in. If - you override this method in your plugin, feel free to remove this parameter. +- Optional :conf:`plugins.musicbrainz:extra_tags` parameter has been removed + from ``BeetsPlugin.candidates`` method signature since it is never passed in. + If you override this method in your plugin, feel free to remove this + parameter. - Loosened ``typing_extensions`` dependency in pyproject.toml to apply to every python version. @@ -552,8 +557,9 @@ New features: :bug:`4348` - Create the parental directories for database if they do not exist. :bug:`3808` :bug:`4327` -- :ref:`musicbrainz-config`: a new :ref:`musicbrainz.enabled` option allows - disabling the MusicBrainz metadata source during the autotagging process +- :ref:`musicbrainz-config`: a new :conf:`plugins.musicbrainz:enabled` option + allows disabling the MusicBrainz metadata source during the autotagging + process - :doc:`/plugins/kodiupdate`: Now supports multiple kodi instances :bug:`4101` - Add the item fields ``bitrate_mode``, ``encoder_info`` and ``encoder_settings``. @@ -586,8 +592,8 @@ New features: :bug:`4561` :bug:`4600` - :ref:`musicbrainz-config`: MusicBrainz release pages often link to related metadata sources like Discogs, Bandcamp, Spotify, Deezer and Beatport. When - enabled via the :ref:`musicbrainz.external_ids` options, release ID's will be - extracted from those URL's and imported to the library. :bug:`4220` + enabled via the :conf:`plugins.musicbrainz:external_ids` options, release ID's + will be extracted from those URL's and imported to the library. :bug:`4220` - :doc:`/plugins/convert`: Add support for generating m3u8 playlists together with converted media files. :bug:`4373` - Fetch the ``release_group_title`` field from MusicBrainz. :bug:`4809` @@ -941,8 +947,9 @@ Other new things: - ``beet remove`` now also allows interactive selection of items from the query, similar to ``beet modify``. -- Enable HTTPS for MusicBrainz by default and add configuration option ``https`` - for custom servers. See :ref:`musicbrainz-config` for more details. +- Enable HTTPS for MusicBrainz by default and add configuration option + :conf:`plugins.musicbrainz:https` for custom servers. See + :ref:`musicbrainz-config` for more details. - :doc:`/plugins/mpdstats`: Add a new ``strip_path`` option to help build the right local path from MPD information. - :doc:`/plugins/convert`: Conversion can now parallelize conversion jobs on @@ -962,8 +969,8 @@ Other new things: server. - :doc:`/plugins/subsonicupdate`: The plugin now automatically chooses between token- and password-based authentication based on the server version. -- A new :ref:`extra_tags` configuration option lets you use more metadata in - MusicBrainz queries to further narrow the search. +- A new :conf:`plugins.musicbrainz:extra_tags` configuration option lets you use + more metadata in MusicBrainz queries to further narrow the search. - A new :doc:`/plugins/fish` adds `Fish shell`_ tab autocompletion to beets. - :doc:`plugins/fetchart` and :doc:`plugins/embedart`: Added a new ``quality`` option that controls the quality of the image output when the image is @@ -1017,9 +1024,9 @@ Other new things: (and now deprecated) separate ``host``, ``port``, and ``contextpath`` config options. As a consequence, the plugin can now talk to Subsonic over HTTPS. Thanks to :user:`jef`. :bug:`3449` -- :doc:`/plugins/discogs`: The new ``index_tracks`` option enables incorporation - of work names and intra-work divisions into imported track titles. Thanks to - :user:`cole-miller`. :bug:`3459` +- :doc:`/plugins/discogs`: The new :conf:`plugins.discogs:index_tracks` option + enables incorporation of work names and intra-work divisions into imported + track titles. Thanks to :user:`cole-miller`. :bug:`3459` - :doc:`/plugins/web`: The query API now interprets backslashes as path separators to support path queries. Thanks to :user:`nmeum`. :bug:`3567` - ``beet import`` now handles tar archives with bzip2 or gzip compression. @@ -1033,9 +1040,9 @@ Other new things: :user:`logan-arens`. :bug:`2947` - There is a new ``--plugins`` (or ``-p``) CLI flag to specify a list of plugins to load. -- A new :ref:`genres` option fetches genre information from MusicBrainz. This - functionality depends on functionality that is currently unreleased in the - python-musicbrainzngs_ library: see PR `#266 +- A new :conf:`plugins.musicbrainz:genres` option fetches genre information from + MusicBrainz. This functionality depends on functionality that is currently + unreleased in the python-musicbrainzngs_ library: see PR `#266 `_. Thanks to :user:`aereaux`. - :doc:`/plugins/replaygain`: Analysis now happens in parallel using the @@ -1075,9 +1082,10 @@ Fixes: :bug:`3867` - :doc:`/plugins/web`: Fixed a small bug that caused the album art path to be redacted even when ``include_paths`` option is set. :bug:`3866` -- :doc:`/plugins/discogs`: Fixed a bug with the ``index_tracks`` option that - sometimes caused the index to be discarded. Also, remove the extra semicolon - that was added when there is no index track. +- :doc:`/plugins/discogs`: Fixed a bug with the + :conf:`plugins.discogs:index_tracks` option that sometimes caused the index to + be discarded. Also, remove the extra semicolon that was added when there is no + index track. - :doc:`/plugins/subsonicupdate`: The API client was using the ``POST`` method rather the ``GET`` method. Also includes better exception handling, response parsing, and tests. @@ -2693,9 +2701,9 @@ Major new features and bigger changes: analysis tool. Thanks to :user:`jmwatte`. :bug:`1343` - A new ``filesize`` field on items indicates the number of bytes in the file. :bug:`1291` -- A new :ref:`search_limit` configuration option allows you to specify how many - search results you wish to see when looking up releases at MusicBrainz during - import. :bug:`1245` +- A new :conf:`plugins.index:search_limit` configuration option allows you to + specify how many search results you wish to see when looking up releases at + MusicBrainz during import. :bug:`1245` - The importer now records the data source for a match in a new flexible attribute ``data_source`` on items and albums. :bug:`1311` - The colors used in the terminal interface are now configurable via the new From 861504d5f6068896f0d9ef120619475334b8fa8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 12 Oct 2025 00:28:44 +0100 Subject: [PATCH 74/83] Make sure conf references are converted properly in release notes --- extra/release.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extra/release.py b/extra/release.py index d4ebb950f..e16814960 100755 --- a/extra/release.py +++ b/extra/release.py @@ -120,7 +120,7 @@ def create_rst_replacements() -> list[Replacement]: # Replace Sphinx directives by documentation URLs, e.g., # :ref:`/plugins/autobpm` -> [AutoBPM Plugin](DOCS/plugins/autobpm.html) ( - r":(?:ref|doc|class):`+(?:([^`<]+)<)?/?([\w./_-]+)>?`+", + r":(?:ref|doc|class|conf):`+(?:([^`<]+)<)?/?([\w.:/_-]+)>?`+", lambda m: make_ref_link(m[2], m[1]), ), # Convert command references to documentation URLs From 9519d47d57e35291d2956e761441baaf49876fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 12 Oct 2025 18:39:37 +0100 Subject: [PATCH 75/83] Convert Python 2 URLs to Python 3 --- docs/dev/plugins/other/logging.rst | 2 +- docs/plugins/export.rst | 2 +- docs/plugins/play.rst | 2 +- docs/reference/config.rst | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/dev/plugins/other/logging.rst b/docs/dev/plugins/other/logging.rst index 1c4ce4838..a26f0c4c0 100644 --- a/docs/dev/plugins/other/logging.rst +++ b/docs/dev/plugins/other/logging.rst @@ -13,7 +13,7 @@ str.format-style string formatting. So you can write logging calls like this: .. _pep 3101: https://www.python.org/dev/peps/pep-3101/ -.. _standard python logging module: https://docs.python.org/2/library/logging.html +.. _standard python logging module: https://docs.python.org/3/library/logging.html When beets is in verbose mode, plugin messages are prefixed with the plugin name to make them easier to see. diff --git a/docs/plugins/export.rst b/docs/plugins/export.rst index a5fa78617..b8e14ef22 100644 --- a/docs/plugins/export.rst +++ b/docs/plugins/export.rst @@ -70,7 +70,7 @@ These options match the options from the `Python csv module`_. .. _python csv module: https://docs.python.org/3/library/csv.html#csv-fmt-params -.. _python json module: https://docs.python.org/2/library/json.html#basic-usage +.. _python json module: https://docs.python.org/3/library/json.html#basic-usage The default options look like this: diff --git a/docs/plugins/play.rst b/docs/plugins/play.rst index 2bc825773..f4b07ac52 100644 --- a/docs/plugins/play.rst +++ b/docs/plugins/play.rst @@ -123,4 +123,4 @@ until they are externally wiped could be an issue for privacy or storage reasons. If this is the case for you, you might want to use the ``raw`` config option described above. -.. _tempfile.tempdir: https://docs.python.org/2/library/tempfile.html#tempfile.tempdir +.. _tempfile.tempdir: https://docs.python.org/3/library/tempfile.html#tempfile.tempdir diff --git a/docs/reference/config.rst b/docs/reference/config.rst index eae9deb21..b4874416c 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -376,7 +376,7 @@ terminal_encoding ~~~~~~~~~~~~~~~~~ The text encoding, as `known to Python -`__, to use +`__, to use for messages printed to the standard output. It's also used to read messages from the standard input. By default, this is determined automatically from the locale environment variables. From d83402fc65e9eef8b6230fd08208a1e8d8dd36fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 19 Oct 2025 01:46:32 +0100 Subject: [PATCH 76/83] Add a changelog note --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 669f1eb50..0fc0ee477 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -46,6 +46,10 @@ Other changes: - :doc:`guides/main`: Modernized the *Getting Started* guide with tabbed sections and dropdown menus. Installation instructions have been streamlined, and a new subpage now provides additional setup details. +- Documentation: introduced a new role ``conf`` for documenting configuration + options. This role provides consistent formatting and creates references + automatically. Applied it to :doc:`plugins/deezer`, :doc:`plugins/discogs`, + :doc:`plugins/musicbrainz` and :doc:`plugins/spotify` plugins documentation. 2.5.0 (October 11, 2025) ------------------------ From e61ecb449675c766f920d04a26a32af06e2e3fb1 Mon Sep 17 00:00:00 2001 From: Martin Atukunda Date: Fri, 10 Oct 2025 08:35:56 +0300 Subject: [PATCH 77/83] fix(github/workflows): update to checkout v5, and setup-python v6. * also run ci against python 3.13, which is default in debian trixie. --- .github/workflows/changelog_reminder.yaml | 2 +- .github/workflows/ci.yaml | 10 +++++----- .github/workflows/integration_test.yaml | 4 ++-- .github/workflows/lint.yml | 18 +++++++++--------- .github/workflows/make_release.yaml | 12 ++++++------ 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.github/workflows/changelog_reminder.yaml b/.github/workflows/changelog_reminder.yaml index a9c26c1f5..380d89996 100644 --- a/.github/workflows/changelog_reminder.yaml +++ b/.github/workflows/changelog_reminder.yaml @@ -10,7 +10,7 @@ jobs: check_changes: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Get all updated Python files id: changed-python-files diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 80826f468..f1623e8a5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,17 +20,17 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] runs-on: ${{ matrix.platform }} env: IS_MAIN_PYTHON: ${{ matrix.python-version == '3.9' && matrix.platform == 'ubuntu-latest' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - name: Setup Python with poetry caching # poetry cache requires poetry to already be installed, weirdly - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: poetry @@ -90,10 +90,10 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Get the coverage report - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: coverage-report diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index f88864c48..8c7e44d7a 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -7,10 +7,10 @@ jobs: test_integration: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: 3.9 cache: poetry diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8fdfa94e5..dcc5d0f12 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -24,7 +24,7 @@ jobs: changed_doc_files: ${{ steps.changed-doc-files.outputs.all_changed_files }} changed_python_files: ${{ steps.changed-python-files.outputs.all_changed_files }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Get changed docs files id: changed-doc-files uses: tj-actions/changed-files@v46 @@ -56,10 +56,10 @@ jobs: name: Check formatting needs: changed-files steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} cache: poetry @@ -77,10 +77,10 @@ jobs: name: Check linting needs: changed-files steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} cache: poetry @@ -97,10 +97,10 @@ jobs: name: Check types with mypy needs: changed-files steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} cache: poetry @@ -120,10 +120,10 @@ jobs: name: Check docs needs: changed-files steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} cache: poetry diff --git a/.github/workflows/make_release.yaml b/.github/workflows/make_release.yaml index b18dded8d..5a8abe5bb 100644 --- a/.github/workflows/make_release.yaml +++ b/.github/workflows/make_release.yaml @@ -17,10 +17,10 @@ jobs: name: Bump version, commit and create tag runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} cache: poetry @@ -45,13 +45,13 @@ jobs: outputs: changelog: ${{ steps.generate_changelog.outputs.changelog }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ env.NEW_TAG }} - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} cache: poetry @@ -92,7 +92,7 @@ jobs: id-token: write steps: - name: Download all the dists - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: python-package-distributions path: dist/ @@ -107,7 +107,7 @@ jobs: CHANGELOG: ${{ needs.build.outputs.changelog }} steps: - name: Download all the dists - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: python-package-distributions path: dist/ From 3ccc91d4d478d6c2625babdff9d3c5e11146a822 Mon Sep 17 00:00:00 2001 From: Martin Atukunda Date: Thu, 16 Oct 2025 09:47:59 +0300 Subject: [PATCH 78/83] Drop 3.13 from python-version for now. --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f1623e8a5..fa6e9a7be 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,7 +20,7 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12"] runs-on: ${{ matrix.platform }} env: IS_MAIN_PYTHON: ${{ matrix.python-version == '3.9' && matrix.platform == 'ubuntu-latest' }} From 1275ccf8c1e6fcd54217ee82059fb493ee8b9129 Mon Sep 17 00:00:00 2001 From: cvx35isl <127420554+cvx35isl@users.noreply.github.com> Date: Sun, 19 Oct 2025 08:38:20 +0200 Subject: [PATCH 79/83] =?UTF-8?q?play=20plugin:=20$playlist=20marker=20for?= =?UTF-8?q?=20precise=20control=20where=20the=20playlist=20=E2=80=A6=20(#4?= =?UTF-8?q?728)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …file is placed in the command ## Description see included doc; placing the playlist filename at the end of command just isn't working for all players I have this in use with `mpv` Co-authored-by: cvx35isl Co-authored-by: J0J0 Todos <2733783+JOJ0@users.noreply.github.com> --- beetsplug/play.py | 21 +++++++++++++++++++++ docs/changelog.rst | 4 ++++ docs/plugins/play.rst | 9 +++++++++ test/plugins/test_play.py | 13 +++++++++++++ 4 files changed, 47 insertions(+) diff --git a/beetsplug/play.py b/beetsplug/play.py index 35b4b1f76..8fb146213 100644 --- a/beetsplug/play.py +++ b/beetsplug/play.py @@ -28,6 +28,11 @@ from beets.util import get_temp_filename # If this is missing, they're placed at the end. ARGS_MARKER = "$args" +# Indicate where the playlist file (with absolute path) should be inserted into +# the command string. If this is missing, its placed at the end, but before +# arguments. +PLS_MARKER = "$playlist" + def play( command_str, @@ -132,8 +137,23 @@ class PlayPlugin(BeetsPlugin): return open_args = self._playlist_or_paths(paths) + open_args_str = [ + p.decode("utf-8") for p in self._playlist_or_paths(paths) + ] command_str = self._command_str(opts.args) + if PLS_MARKER in command_str: + if not config["play"]["raw"]: + command_str = command_str.replace( + PLS_MARKER, "".join(open_args_str) + ) + self._log.debug( + "command altered by PLS_MARKER to: {}", command_str + ) + open_args = [] + else: + command_str = command_str.replace(PLS_MARKER, " ") + # Check if the selection exceeds configured threshold. If True, # cancel, otherwise proceed with play command. if opts.yes or not self._exceeds_threshold( @@ -162,6 +182,7 @@ class PlayPlugin(BeetsPlugin): return paths else: return [self._create_tmp_playlist(paths)] + return [shlex.quote(self._create_tmp_playlist(paths))] def _exceeds_threshold( self, selection, command_str, open_args, item_type="track" diff --git a/docs/changelog.rst b/docs/changelog.rst index 0fc0ee477..5c6224de9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,6 +10,8 @@ Unreleased New features: - :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle. +- :doc: `/plugins/play`: Added `$playlist` marker to precisely edit the playlist + filepath into the command calling the player program. Bug fixes: @@ -71,6 +73,8 @@ New features: :bug:`3354` - :doc:`plugins/discogs` Support for name variations and config options to specify where the variations are written. :bug:`3354` +- :doc: `/plugins/play`: Added `$playlist` marker to precisely edit the playlist + filepath into the command calling the player program. Bug fixes: diff --git a/docs/plugins/play.rst b/docs/plugins/play.rst index f4b07ac52..f06eb4cb3 100644 --- a/docs/plugins/play.rst +++ b/docs/plugins/play.rst @@ -107,6 +107,15 @@ string, use ``$args`` to indicate where to insert them. For example: indicates that you need to insert extra arguments before specifying the playlist. +Some players require a different syntax. For example, with ``mpv`` the optional +``$playlist`` variable can be used to match the syntax of the ``--playlist`` +option: + +:: + + play: + command: mpv $args --playlist=$playlist + The ``--yes`` (or ``-y``) flag to the ``play`` command will skip the warning message if you choose to play more items than the **warning_threshold** value usually allows. diff --git a/test/plugins/test_play.py b/test/plugins/test_play.py index 293a50a20..b184db63f 100644 --- a/test/plugins/test_play.py +++ b/test/plugins/test_play.py @@ -105,6 +105,19 @@ class PlayPluginTest(CleanupModulesMixin, PluginTestCase): open_mock.assert_called_once_with([self.item.path], "echo") + def test_pls_marker(self, open_mock): + self.config["play"]["command"] = ( + "echo --some params --playlist=$playlist --some-more params" + ) + + self.run_command("play", "nice") + + open_mock.assert_called_once + + commandstr = open_mock.call_args_list[0][0][1] + assert commandstr.startswith("echo --some params --playlist=") + assert commandstr.endswith(" --some-more params") + def test_not_found(self, open_mock): self.run_command("play", "not found") From 39aadf709932a2a5ad2e9f69378a09b50fe9b78c Mon Sep 17 00:00:00 2001 From: J0J0 Todos <2733783+JOJ0@users.noreply.github.com> Date: Sun, 19 Oct 2025 08:50:25 +0200 Subject: [PATCH 80/83] Remove duplicate changelog entry (play plugin) --- docs/changelog.rst | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5c6224de9..449ca6dd3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -73,8 +73,6 @@ New features: :bug:`3354` - :doc:`plugins/discogs` Support for name variations and config options to specify where the variations are written. :bug:`3354` -- :doc: `/plugins/play`: Added `$playlist` marker to precisely edit the playlist - filepath into the command calling the player program. Bug fixes: From 8a24518c4c0bdbcde5d60e35599d742274752904 Mon Sep 17 00:00:00 2001 From: Konstantin <78656278+amogus07@users.noreply.github.com> Date: Sun, 19 Oct 2025 10:06:16 +0200 Subject: [PATCH 81/83] use `Generic` instead of `Any` for `cached_classproperty` --- beets/util/__init__.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 0f2ef5b97..fc05e4997 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -47,6 +47,7 @@ from typing import ( NamedTuple, TypeVar, Union, + cast, ) from unidecode import unidecode @@ -1052,7 +1053,7 @@ def par_map(transform: Callable[[T], Any], items: Sequence[T]) -> None: pool.join() -class cached_classproperty: +class cached_classproperty(Generic[T]): """Descriptor implementing cached class properties. Provides class-level dynamic property behavior where the getter function is @@ -1060,9 +1061,9 @@ class cached_classproperty: instance properties, this operates on the class rather than instances. """ - cache: ClassVar[dict[tuple[Any, str], Any]] = {} + cache: ClassVar[dict[tuple[type[object], str], object]] = {} - name: str + name: str = "" # Ideally, we would like to use `Callable[[type[T]], Any]` here, # however, `mypy` is unable to see this as a **class** property, and thinks @@ -1078,21 +1079,21 @@ class cached_classproperty: # "Callable[[Album], ...]"; expected "Callable[[type[Album]], ...]" # # Therefore, we just use `Any` here, which is not ideal, but works. - def __init__(self, getter: Callable[[Any], Any]) -> None: + def __init__(self, getter: Callable[..., T]) -> None: """Initialize the descriptor with the property getter function.""" - self.getter = getter + self.getter: Callable[..., T] = getter - def __set_name__(self, owner: Any, name: str) -> None: + def __set_name__(self, owner: object, name: str) -> None: """Capture the attribute name this descriptor is assigned to.""" self.name = name - def __get__(self, instance: Any, owner: type[Any]) -> Any: + def __get__(self, instance: object, owner: type[object]) -> T: """Compute and cache if needed, and return the property value.""" - key = owner, self.name + key: tuple[type[object], str] = owner, self.name if key not in self.cache: self.cache[key] = self.getter(owner) - return self.cache[key] + return cast(T, self.cache[key]) class LazySharedInstance(Generic[T]): From d7138062639ed237f0ce92dfcead9459f292efbb Mon Sep 17 00:00:00 2001 From: Konstantin <78656278+amogus07@users.noreply.github.com> Date: Sun, 19 Oct 2025 15:07:17 +0200 Subject: [PATCH 82/83] fix transaction context manager signature --- .gitignore | 3 +++ beets/dbcore/db.py | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 90ef7387d..138965b22 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,6 @@ ENV/ # pyright pyrightconfig.json + +# Pyrefly +pyrefly.toml diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 192cfac70..4bcc8e9c1 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -940,10 +940,10 @@ class Transaction: def __exit__( self, - exc_type: type[Exception], - exc_value: Exception, - traceback: TracebackType, - ): + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool | None: """Complete a transaction. This must be the most recently entered but not yet exited transaction. If it is the last active transaction, the database updates are committed. From 12f2a1f6943d65487c02bf29d0fb129456176bad Mon Sep 17 00:00:00 2001 From: Konstantin <78656278+amogus07@users.noreply.github.com> Date: Sun, 19 Oct 2025 15:12:27 +0200 Subject: [PATCH 83/83] fix mypy error --- beets/dbcore/db.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 4bcc8e9c1..afae6e906 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -965,6 +965,8 @@ class Transaction: ): raise DBCustomFunctionError() + return None + def query( self, statement: str, subvals: Sequence[SQLiteType] = () ) -> list[sqlite3.Row]: