From 8613b3573c14d8bdf082c218f6aeb456307b5d57 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 14 Sep 2025 09:03:32 +0200 Subject: [PATCH 01/19] 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 02/19] 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 03/19] 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 04/19] 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 05/19] 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 06/19] 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 07/19] 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 08/19] 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 09/19] 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 10/19] 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 11/19] 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 12/19] 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 13/19] 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 14/19] 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 15/19] 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 16/19] 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 17/19] 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 18/19] =?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 19/19] 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: