From 8613b3573c14d8bdf082c218f6aeb456307b5d57 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 14 Sep 2025 09:03:32 +0200 Subject: [PATCH 1/9] 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 | 104 ++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 46 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 1da5ecde47..301c94377d 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -24,6 +24,7 @@ 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 @@ def __init__(self): "prefer_specific": False, "title_case": True, "extended_debug": False, + "pretend": False, } ) self.setup() @@ -459,6 +483,21 @@ def _try_resolve_stage(stage_label: str, keep_genres, new_genres): # 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 @@ def commands(self): 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() - - if write and not pretend: - item.try_write() + 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() + 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 2/9] 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 301c94377d..b67a4476f3 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -605,34 +605,21 @@ def imported(self, session, task): """Event hook called when an import task finishes.""" if task.is_album: album = task.album - album.genre, src = self._get_genre(album) - self._log.debug( - 'genre for album "{0.album}" ({1}): {0.genre}', album, src - ) + 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 3/9] 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 d6df42f977..c3d4984d7d 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -158,7 +158,8 @@ def test_pretend_option_skips_library_updates(self): 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 4/9] 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 c3d4984d7d..91dd7c2822 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 @@ def test_prefer_specific_without_canonical(self): "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 @@ def test_sort_by_depth(self): 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 5/9] 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 6f0523a42f..a8196cb1e7 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 0058c7f9b2..5eb82f6c71 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 6/9] 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 a8196cb1e7..6f0523a42f 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 5eb82f6c71..0058c7f9b2 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 91dd7c2822..151f122a60 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 @@ def test_prefer_specific_without_canonical(self): "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 @@ def test_sort_by_depth(self): 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 7/9] 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 b67a4476f3..80374b9621 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -498,6 +498,27 @@ def _apply_item_genre(self, obj, label, genre): 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 @@ def lastgenre_func(lib, opts, args): 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 @@ def lastgenre_func(lib, opts, args): 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 8/9] 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 80374b9621..8bd33ff5d1 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -22,11 +22,13 @@ 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.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 @@ def _format_and_stringify(self, tags: list[str]) -> str: 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 @@ def _combine_resolve_and_log( 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 @@ def _try_resolve_stage(stage_label: str, keep_genres, new_genres): # 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 @@ def commands(self): 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 9/9] 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 e0c1bb486c..60e2014485 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 8bd33ff5d1..902cef9eff 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -468,7 +468,9 @@ 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) + 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 151f122a60..12ff30f8ed 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -152,13 +152,10 @@ 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") + 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"