mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 08:39:17 +01:00
lastgenre_pretend_refactor
This commit is contained in:
parent
64c94f61b7
commit
0a26202a1d
5 changed files with 337 additions and 119 deletions
|
|
@ -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]
|
||||
|
|
@ -593,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.
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ Other changes:
|
|||
``beetsplug._utils``.
|
||||
- :class:`beets.metadata_plugin.MetadataSourcePlugin`: Remove discogs specific
|
||||
disambiguation stripping.
|
||||
- :doc:`plugins/lastgenre`: Refactor code and test around genre applying and
|
||||
pretend mode :bug:`#6021`
|
||||
|
||||
2.4.0 (September 13, 2025)
|
||||
--------------------------
|
||||
|
|
|
|||
235
poetry.lock
generated
235
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -98,6 +98,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"
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
"""Tests for the 'lastgenre' plugin."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -131,43 +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[1] == "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)
|
||||
|
|
@ -209,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",
|
||||
[
|
||||
|
|
|
|||
Loading…
Reference in a new issue