From a78c94c443184179866c53d7b290023e6ada4ed8 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Mon, 29 Jun 2026 20:49:49 +0200 Subject: [PATCH 1/7] Changelog for #6466 genre aliases --- docs/changelog.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index d7e9c83fd3..81cd060c7d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -39,6 +39,14 @@ New features the local filesystem when the user (or ``quiet_fallback``) chooses ``asis``. - :doc:`plugins/lyrics`: Added a ``--no-keep-synced`` command option to override ``keep_synced: yes`` for a single manual lyrics fetch. +- :doc:`plugins/lastgenre`: Add support for normalizing genre spellings and + naming variants with a new configuration option ``aliases``. The feature is + enabled by default and ships with a built-in list of regex patterns. These + patterns can be replaced via the user's configuration. The default whitelist + and genre tree were audited against the top 1,000 Last.fm tags: canonical + names are now consistent across both files, long-standing mismatches between + them have been resolved, and entries align with the built-in alias patterns. + :bug:`6466` Bug fixes ~~~~~~~~~ @@ -92,6 +100,9 @@ Other changes information. :bug:`5783` - :doc:`guides/installation`: Switch isolated tool installation guidance and GitHub workflow setup to ``uv tool`` commands. +- :doc:`plugins/lastgenre`: Add a new "Choosing the Right Tool" documentation + section to guide users in picking the right approach across genre fetching, + filtering, and normalization. 2.12.0 (June 22, 2026) ---------------------- From 4b9fa31a5bf1dd322b19727dd0b654deae602435 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Wed, 24 Jun 2026 07:33:09 +0200 Subject: [PATCH 2/7] lastgenre: Genre aliases; Refactoring - Genre alias normalization feature implementation - Extend and simplify _filter_valid() - Remove drop_ignored_genres() - More smaller ignorelist related refactoring --- beetsplug/lastgenre/__init__.py | 107 +++++++++++++++++++++++++------- beetsplug/lastgenre/client.py | 22 ++++--- beetsplug/lastgenre/utils.py | 55 +++++++++++----- 3 files changed, 139 insertions(+), 45 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index bd245dcf09..71355223be 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -22,7 +22,7 @@ from beets import config, library, plugins, ui from beets.library import Album, Item from beets.util import plurality, unique_list -from beetsplug.lastgenre.utils import drop_ignored_genres, is_ignored +from beetsplug.lastgenre.utils import is_ignored, normalize_genre from .client import LastFmClient @@ -33,7 +33,7 @@ from beets.importer import ImportSession, ImportTask from beets.library import LibModel - from .utils import GenreIgnorePatterns + from .utils import AliasPatternWithReplacement, IgnorePatternsByArtist Whitelist = set[str] """Set of valid genre names (lowercase). Empty set means all genres allowed.""" @@ -98,6 +98,7 @@ def sort_by_depth(tags: list[str], branches: CanonTree) -> list[str]: WHITELIST = os.path.join(os.path.dirname(__file__), "genres.txt") C14N_TREE = os.path.join(os.path.dirname(__file__), "genres-tree.yaml") +ALIASES_FILE = os.path.join(os.path.dirname(__file__), "aliases.yaml") class LastGenrePlugin(plugins.BeetsPlugin): @@ -120,6 +121,7 @@ def __init__(self) -> None: "title_case": True, "pretend": False, "ignorelist": {}, + "aliases": True, } ) self.setup() @@ -132,9 +134,15 @@ def setup(self) -> None: self.whitelist: Whitelist = self._load_whitelist() self.c14n_branches: CanonTree self.c14n_branches, self.canonicalize = self._load_c14n_tree() - self.ignore_patterns: GenreIgnorePatterns = self._load_ignorelist() + self.ignore_patterns: IgnorePatternsByArtist = self._load_ignorelist() + self.alias_patterns: list[AliasPatternWithReplacement] = ( + self._load_aliases() + ) self.client = LastFmClient( - self._log, self.config["min_weight"].get(int), self.ignore_patterns + self._log, + self.config["min_weight"].get(int), + self.ignore_patterns, + self.alias_patterns, ) def _load_whitelist(self) -> Whitelist: @@ -178,7 +186,7 @@ def _load_c14n_tree(self) -> tuple[CanonTree, bool]: flatten_tree(genres_tree, [], c14n_branches) return c14n_branches, canonicalize - def _load_ignorelist(self) -> GenreIgnorePatterns: + def _load_ignorelist(self) -> IgnorePatternsByArtist: r"""Load patterns from configuration and compile them. Mapping of artist names to regex or literal patterns. Use the @@ -209,7 +217,7 @@ def _load_ignorelist(self) -> GenreIgnorePatterns: confuse.MappingValues(confuse.Sequence(str)) ) - compiled_ignorelist: GenreIgnorePatterns = defaultdict(list) + compiled_ignorelist: IgnorePatternsByArtist = defaultdict(list) for artist, patterns in raw_ignorelist.items(): artist_patterns = [] for pattern in patterns: @@ -225,10 +233,62 @@ def _load_ignorelist(self) -> GenreIgnorePatterns: [p.pattern for p in artist_patterns], ) - compiled_ignorelist[artist] = artist_patterns + compiled_ignorelist[artist.lower()] = artist_patterns return compiled_ignorelist + def _load_aliases(self) -> list[AliasPatternWithReplacement]: + """Load the genre alias table from the beets config. + + ``lastgenre.aliases`` is a tri-state option: + + - ``yes`` (default): load the built-in aliases. + - ``no``: disable alias normalization entirely. + - mapping: an inline dict of canonical genre names to lists of regex + patterns. + + The key (genre name) is used as a ``re.Match.expand()`` template, + so ``\\1`` / ``\\g`` back-references to capture groups are supported. + + Raises: + confuse.ConfigTypeError: when the config value is not a bool or + mapping, or when a mapping value is not a list. + re.error: when a pattern is not valid regex syntax. + """ + aliases_config = self.config["aliases"].get() + if aliases_config is False: + return [] + + # Define view with either built-in or user-configured + aliases_view = confuse.Configuration( + self.config["aliases"].name, read=False + ) + if aliases_config in (True, "", None): + self._log.debug("Loading built-in aliases") + with Path(ALIASES_FILE).open(encoding="utf-8") as f: + aliases_view.set(yaml.safe_load(f)) + elif not isinstance(aliases_config, dict): + raise confuse.ConfigTypeError( + f"{self.config['aliases'].name} must be a dict or bool." + ) + else: + aliases_view.set(aliases_config) + + # Parse and compile. Raise for invalid regex! + raw_aliases = aliases_view.get( + confuse.MappingValues(confuse.Sequence(str)) + ) + compiled_aliases: list[AliasPatternWithReplacement] = [] + for canonical, patterns in raw_aliases.items(): + lower_canonical = canonical.lower() + compiled_aliases.extend( + (re.compile(p, re.IGNORECASE), lower_canonical) + for p in patterns + ) + + self._log.debug("Loaded {} alias entries", len(compiled_aliases)) + return compiled_aliases + @property def sources(self) -> tuple[str, ...]: """A tuple of allowed genre sources. May contain 'track', @@ -250,6 +310,8 @@ def _resolve_genres( """Canonicalize, sort and filter a list of genres. - Returns an empty list if the input tags list is empty. + - If aliases are configured, variant spellings are normalised first + (e.g. 'hip-hop' → 'hip hop', 'dnb' → 'drum and bass'). - If canonicalization is enabled, it extends the list by incorporating parent genres from the canonicalization tree. When a whitelist is set, only parent tags that pass the whitelist filter are included; @@ -269,6 +331,13 @@ def _resolve_genres( if not tags: return [] + # Normalize variant spellings before any other processing. + if self.alias_patterns: + tags = [ + normalize_genre(self._log, self.alias_patterns, tag) + for tag in tags + ] + count = self.config["count"].get(int) # Canonicalization (if enabled) @@ -325,24 +394,18 @@ def _filter_valid( ) -> list[str]: """Filter genres through whitelist and ignorelist. - Drops empty/whitespace-only strings, then applies whitelist and - ignorelist checks. Returns all genres if neither is configured. - Whitelist is checked first for performance reasons (ignorelist regex - matching is more expensive and for some call sites ignored genres were - already filtered). + Strips leading/trailing whitespace and drops empty strings, then + applies whitelist and ignorelist checks. Whitelist is checked first + for performance reasons (ignorelist regex matching is more expensive + and for some call sites ignored genres were already filtered). """ - cleaned = [g for g in genres if g and g.strip()] - if not self.whitelist and not self.ignore_patterns: - return cleaned - - whitelisted = [ + non_blank = [s for g in genres if (s := g.strip())] + return [ g - for g in cleaned - if not self.whitelist or g.lower() in self.whitelist + for g in non_blank + if (not self.whitelist or g.lower() in self.whitelist) + and not is_ignored(self._log, self.ignore_patterns, g, artist) ] - return drop_ignored_genres( - self._log, self.ignore_patterns, whitelisted, artist - ) # Genre resolution pipeline. diff --git a/beetsplug/lastgenre/client.py b/beetsplug/lastgenre/client.py index 25f9a54590..412613c1ad 100644 --- a/beetsplug/lastgenre/client.py +++ b/beetsplug/lastgenre/client.py @@ -9,7 +9,7 @@ from beets import plugins -from .utils import drop_ignored_genres +from .utils import is_ignored, normalize_genre if TYPE_CHECKING: from collections.abc import Callable @@ -17,7 +17,7 @@ from beets.library import LibModel from beets.logging import BeetsLogger - from .utils import GenreIgnorePatterns + from .utils import AliasPatternWithReplacement, IgnorePatternsByArtist GenreCache = dict[str, list[str]] """Cache mapping entity keys to their genre lists. @@ -52,7 +52,8 @@ def __init__( self, log: BeetsLogger, min_weight: int, - ignore_patterns: GenreIgnorePatterns, + ignore_patterns: IgnorePatternsByArtist, + alias_patterns: list[AliasPatternWithReplacement], ): """Initialize the client. @@ -61,7 +62,8 @@ def __init__( """ self._log = log self._min_weight = min_weight - self._ignore_patterns: GenreIgnorePatterns = ignore_patterns + self._ignore_patterns: IgnorePatternsByArtist = ignore_patterns + self.alias_patterns: list[AliasPatternWithReplacement] = alias_patterns self._genre_cache: GenreCache = {} def fetch_genres( @@ -111,11 +113,17 @@ def _last_lookup( "last.fm (unfiltered) {} tags: {}", entity, genres ) + # Apply aliases and log each change. # Filter forbidden genres on every call so ignorelist hits are logged. # Artist is always the first element in args (album, artist, track lookups). - return drop_ignored_genres( - self._log, self._ignore_patterns, genres, args[0] - ) + return [ + normal + for g in genres + if (normal := normalize_genre(self._log, self.alias_patterns, g)) + and not is_ignored( + self._log, self._ignore_patterns, normal, args[0] + ) + ] def fetch(self, kind: str, obj: LibModel, *args: str) -> list[str]: """Fetch Last.fm genres for the specified kind and entity. diff --git a/beetsplug/lastgenre/utils.py b/beetsplug/lastgenre/utils.py index 60fc8853e6..8d29dcd4ac 100644 --- a/beetsplug/lastgenre/utils.py +++ b/beetsplug/lastgenre/utils.py @@ -2,32 +2,22 @@ from __future__ import annotations +import re from typing import TYPE_CHECKING if TYPE_CHECKING: - import re - from beets.logging import BeetsLogger - GenreIgnorePatterns = dict[str, list[re.Pattern[str]]] - """Mapping of artist name to list of compiled case-insensitive patterns.""" + IgnorePatternsByArtist = dict[str, list[re.Pattern[str]]] + """Mapping of artist key to list of compiled case-insensitive patterns.""" - -def drop_ignored_genres( - logger: BeetsLogger, - ignore_patterns: GenreIgnorePatterns, - genres: list[str], - artist: str | None = None, -) -> list[str]: - """Drop genres that match the ignorelist.""" - return [ - g for g in genres if not is_ignored(logger, ignore_patterns, g, artist) - ] + AliasPatternWithReplacement = tuple[re.Pattern[str], str] + """A compiled alias regex paired with replacement template string.""" def is_ignored( logger: BeetsLogger, - ignore_patterns: GenreIgnorePatterns, + ignore_patterns: IgnorePatternsByArtist, genre: str, artist: str | None = None, ) -> bool: @@ -42,3 +32,36 @@ def is_ignored( logger.extra_debug("ignored (artist: {}): {}", artist, genre) return True return False + + +def normalize_genre( + logger: BeetsLogger, + alias_patterns: list[AliasPatternWithReplacement], + genre: str, +) -> str: + """Normalize genre using alias replacements. + + Tries each alias entry in order. The first full-match wins; the replacement + template is expanded via ``re.Match.expand()`` so ``\\1`` / ``\\g`` + back-references work. Returns original (lowercased) *genre* when no alias + matches. + """ + genre_lower = genre.lower() + for pattern, template in alias_patterns: + if m := pattern.fullmatch(genre_lower): + try: + expanded = m.expand(template) + except (re.error, IndexError) as exc: + logger.warning( + "invalid alias template {}; skipping for genre {}: {}", + template, + genre, + exc, + ) + continue + if expanded != genre: + logger.extra_debug( + "replaced using 'aliases': {} -> {}", genre, expanded + ) + return expanded + return genre_lower From e3568273ee9ee408fdd32721436897189cc6c799 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Mon, 29 Jun 2026 08:08:20 +0200 Subject: [PATCH 3/7] lastgenre: Sync instance var naming client and main plugin classes use the same names for instance variables now --- beetsplug/lastgenre/client.py | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/beetsplug/lastgenre/client.py b/beetsplug/lastgenre/client.py index 412613c1ad..edcfe1224f 100644 --- a/beetsplug/lastgenre/client.py +++ b/beetsplug/lastgenre/client.py @@ -60,11 +60,11 @@ def __init__( The min_weight parameter filters tags by their minimum weight. The ignorelist filters forbidden genres directly after Last.fm lookup. """ - self._log = log - self._min_weight = min_weight - self._ignore_patterns: IgnorePatternsByArtist = ignore_patterns + self.log = log + self.min_weight = min_weight + self.ignore_patterns: IgnorePatternsByArtist = ignore_patterns self.alias_patterns: list[AliasPatternWithReplacement] = alias_patterns - self._genre_cache: GenreCache = {} + self.genre_cache: GenreCache = {} def fetch_genres( self, obj: pylast.Album | pylast.Artist | pylast.Track @@ -73,16 +73,16 @@ def fetch_genres( try: res = obj.get_top_tags() except PYLAST_EXCEPTIONS as exc: - self._log.debug("last.fm error: {}", exc) + self.log.debug("last.fm error: {}", exc) return [] except Exception as exc: # Isolate bugs in pylast. - self._log.debug("{}", traceback.format_exc()) - self._log.error("error in pylast library: {}", exc) + self.log.debug("{}", traceback.format_exc()) + self.log.error("error in pylast library: {}", exc) return [] # Filter by weight (optionally). - if min_weight := self._min_weight: + if min_weight := self.min_weight: res = [el for el in res if (int(el.weight or 0)) >= min_weight] # Get strings from tags. @@ -105,13 +105,11 @@ def _last_lookup( args_replaced = [a.replace("\u2010", "-") for a in args] key = f"{entity}.{'-'.join(str(a) for a in args_replaced)}" - if key not in self._genre_cache: - self._genre_cache[key] = self.fetch_genres(method(*args_replaced)) + if key not in self.genre_cache: + self.genre_cache[key] = self.fetch_genres(method(*args_replaced)) - genres = self._genre_cache[key] - self._log.extra_debug( - "last.fm (unfiltered) {} tags: {}", entity, genres - ) + genres = self.genre_cache[key] + self.log.extra_debug("last.fm (unfiltered) {} tags: {}", entity, genres) # Apply aliases and log each change. # Filter forbidden genres on every call so ignorelist hits are logged. @@ -119,10 +117,8 @@ def _last_lookup( return [ normal for g in genres - if (normal := normalize_genre(self._log, self.alias_patterns, g)) - and not is_ignored( - self._log, self._ignore_patterns, normal, args[0] - ) + if (normal := normalize_genre(self.log, self.alias_patterns, g)) + and not is_ignored(self.log, self.ignore_patterns, normal, args[0]) ] def fetch(self, kind: str, obj: LibModel, *args: str) -> list[str]: From 1674b5e7f98106f948a205812b01009ae71f303f Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Wed, 24 Jun 2026 07:53:57 +0200 Subject: [PATCH 4/7] lastgenre: Tests for genre alias normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Šarūnas Nejus --- test/plugins/test_lastgenre.py | 223 ++++++++++++++++++++++++++++++--- 1 file changed, 209 insertions(+), 14 deletions(-) diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index ea1794116e..7e1f1ae732 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -11,7 +11,7 @@ from beets.test import _common from beets.test.helper import IOMixin, PluginTestCase from beetsplug import lastgenre -from beetsplug.lastgenre.utils import is_ignored +from beetsplug.lastgenre.utils import is_ignored, normalize_genre _p = pytest.param @@ -182,7 +182,7 @@ def get_top_tags(self): res = plugin.client.fetch_genres(MockPylastObj()) assert res == ["pop", "rap"] - plugin.client._min_weight = 50 + plugin.client.min_weight = 50 res = plugin.client.fetch_genres(MockPylastObj()) assert res == ["pop"] @@ -192,8 +192,8 @@ def test_sort_by_depth(self): tags = ("electronic", "ambient", "post-rock", "downtempo") res = lastgenre.sort_by_depth(tags, self.plugin.c14n_branches) assert res == ["post-rock", "downtempo", "ambient", "electronic"] - # Non-canonical tag ('chillout') present. - tags = ("electronic", "ambient", "chillout") + # Non-canonical tag ('chill out') present. + tags = ("electronic", "ambient", "chill out") res = lastgenre.sort_by_depth(tags, self.plugin.c14n_branches) assert res == ["ambient", "electronic"] @@ -722,14 +722,14 @@ def test_ignorelist_patterns( logger = Mock() - # Set up compiled ignorelist directly (skipping file parsing) - compiled_ignorelist = defaultdict(list) + # Set up compiled patterns directly (skipping file parsing) + ignore_patterns = defaultdict(list) for artist_name, patterns in ignorelist_dict.items(): - compiled_ignorelist[artist_name.lower()] = [ + ignore_patterns[artist_name.lower()] = [ re.compile(pattern, re.IGNORECASE) for pattern in patterns ] - result = is_ignored(logger, compiled_ignorelist, genre, artist) + result = is_ignored(logger, ignore_patterns, genre, artist) assert result == expected_forbidden @pytest.mark.parametrize( @@ -749,8 +749,6 @@ def test_ignorelist_patterns( {"*": ["spoken word"], "metallica": ["metal"]}, {"*": ["spoken word"], "metallica": ["metal"]}, ), - # Artist names are preserved by the current loader implementation. - ({"METALLICA": ["METAL"]}, {"METALLICA": ["METAL"]}), # Invalid regex pattern that gets escaped (full-match literal fallback) ( {"artist": ["[invalid(regex"]}, @@ -770,12 +768,12 @@ def test_ignorelist_config_format( # Mimic the plugin loader behavior in isolation to avoid global config bleed. if not cfg["lastgenre"]["ignorelist"].get(): - string_ignorelist = {} + ignore_patterns = {} else: raw_strs = cfg["lastgenre"]["ignorelist"].get( confuse.MappingValues(confuse.Sequence(str)) ) - string_ignorelist = {} + ignore_patterns = {} for artist, patterns in raw_strs.items(): compiled_patterns = [] for pattern in patterns: @@ -789,9 +787,9 @@ def test_ignorelist_config_format( re.escape(pattern), re.IGNORECASE ).pattern ) - string_ignorelist[artist] = compiled_patterns + ignore_patterns[artist.lower()] = compiled_patterns - assert string_ignorelist == expected_ignorelist + assert ignore_patterns == expected_ignorelist @pytest.mark.parametrize( "invalid_config, expected_error_message", @@ -855,3 +853,200 @@ def fake_fetch(_, kind, obj, *args): assert "multi-valued album artist" in label assert "Metal" in genres + + +class TestAliases: + """Alias pattern matching and loading tests.""" + + @pytest.mark.parametrize( + "aliases_dict, genre, expected", + [ + # Static replacement + ({"foo bar": ["foobar"]}, "foobar", "foo bar"), + # Template with back-reference + ({r"\1 music": [r"(fake)[ /-]*music"]}, "fake-music", "fake music"), + # Template with multiple back-references + ({r"\1-\2": [r"(x)[ /-]*(y)"]}, "x y", "x-y"), + # Case-insensitive matching + ({"foo bar": ["foobar"]}, "FOOBAR", "foo bar"), + # No match — genre returned as-is (lowercased) + ({"foo bar": ["foobar"]}, "jazz", "jazz"), + # Empty alias list → no-op + ({}, "something", "something"), + ], + ) + def test_normalize_genre( + self, aliases_dict: dict[str, list[str]], genre: str, expected: str + ) -> None: + """Test normalize_genre() with static and template canonical names.""" + alias_patterns = [ + (re.compile(pat, re.IGNORECASE), template.lower()) + for template, patterns in aliases_dict.items() + for pat in patterns + ] + assert normalize_genre(Mock(), alias_patterns, genre) == expected + + def test_normalize_genre_invalid_template_does_not_crash(self) -> None: + """Invalid replacement templates are skipped instead of crashing.""" + logger = Mock() + alias_patterns = [ + (re.compile(r"(hip)[ /-]*hop", re.IGNORECASE), r"\g<2> hop") + ] + + assert normalize_genre(logger, alias_patterns, "hip-hop") == "hip-hop" + logger.warning.assert_called_once() + + def test_aliases_config_format(self, config): + """Test _load_aliases() loading from inline config dict.""" + # Multi-pattern list: proves all patterns are loaded, not just the first + config["lastgenre"]["aliases"] = {"hip hop": ["hip-hop", "hiphop"]} + plugin = lastgenre.LastGenrePlugin() + assert ( + normalize_genre(plugin._log, plugin.alias_patterns, "hip-hop") + == "hip hop" + ) + assert ( + normalize_genre(plugin._log, plugin.alias_patterns, "hiphop") + == "hip hop" + ) + + @pytest.mark.parametrize( + "invalid_config, expected_error", + [ + # Plain string instead of mapping + ("/path/to/aliases.txt", "must be a dict"), + # Integer + (42, "must be a dict"), + # Mapping with non-list value + ({"hip hop": "hip-hop"}, "must be a list"), + ], + ) + def test_aliases_config_format_errors( + self, config, invalid_config, expected_error + ): + """Test that invalid aliases config values raise confuse.ConfigTypeError.""" + config["lastgenre"]["aliases"] = invalid_config + with pytest.raises(confuse.ConfigTypeError) as exc_info: + lastgenre.LastGenrePlugin() + assert expected_error in str(exc_info.value) + + def test_normalize_before_whitelist(self, config): + """Aliases normalize BEFORE whitelist filtering. + + 'hip-hop' is not on the whitelist but 'hip hop' is. With aliases + enabled the tag must survive whitelist filtering. + """ + config["lastgenre"]["aliases"] = {"hip hop": ["hip-hop", "hiphop"]} + plugin = lastgenre.LastGenrePlugin() + plugin.setup() + # Inject only 'hip hop' into the whitelist to prove the alias fired. + plugin.whitelist = {"hip hop"} + + result = plugin._resolve_genres(["hip-hop"]) + assert result == ["hip hop"], ( + "alias must normalize 'hip-hop' → 'hip hop' before whitelist check" + ) + + def test_normalize_before_ignorelist(self, config): + """Aliases normalize BEFORE ignorelist filtering. + + If 'hip hop' is ignored but 'hip-hop' is fed in, the alias fires first + so the result is empty (correctly ignored). + """ + config["lastgenre"]["aliases"] = {"hip hop": ["hip-hop"]} + plugin = lastgenre.LastGenrePlugin() + plugin.setup() + plugin.ignore_patterns = {"*": [re.compile("hip hop", re.IGNORECASE)]} + + result = plugin._resolve_genres(["hip-hop"]) + assert result == [], ( + "alias must normalize 'hip-hop' before ignorelist check drops it" + ) + + def test_disabled(self, config): + """With aliases: false, no normalization is performed.""" + config["lastgenre"]["aliases"] = False + plugin = lastgenre.LastGenrePlugin() + assert plugin.alias_patterns == [] + # normalize_genre with an empty list must return the genre unchanged. + assert ( + normalize_genre(plugin._log, plugin.alias_patterns, "hip-hop") + == "hip-hop" + ) + + @pytest.mark.parametrize( + "input_genre, expected_genre", + [ + ("dnb", "drum and bass"), + ("drum n bass", "drum and bass"), + ("r&b", "rhythm and blues"), + ("rnb", "rhythm and blues"), + ("rock & roll", "rock and roll"), + ("rock'n'roll", "rock and roll"), + ("kpop", "k-pop"), + ("j rock", "j-rock"), + ("post rock", "post-rock"), + ("lofi", "lo-fi"), + ("lo fi", "lo-fi"), + ("p funk", "p-funk"), + ("synth pop", "synthpop"), + ("avantgarde", "avant-garde"), + ("avant gard", "avant-garde"), + ("nu-jazz", "nu jazz"), + ("nu-metal", "nu metal"), + ("nu-soul", "nu soul"), + ("nu-disco", "nu disco"), + ("nu - disco", "nu disco"), + ("electronic music", "electronic"), + ("world", "world music"), + ("chill", "chillout"), + ("chill out", "chillout"), + ("chill-out", "chillout"), + ("dark wave", "darkwave"), + ("dark-wave", "darkwave"), + ("blues rock", "blues rock"), + ("blues-rock", "blues rock"), + ("folk-rock", "folk rock"), + ("downbeat", "downtempo"), + ("shoegazer", "shoegaze"), + ("shoegazing", "shoegaze"), + ("hip-hop", "hip hop"), + ("triphop", "trip hop"), + ("alt", "alternative rock"), + ("alt rock", "alternative rock"), + ("alternative", "alternative rock"), + ("goth", "gothic rock"), + ("goth rock", "gothic rock"), + ("gothic rock", "gothic rock"), + ("prog", "progressive rock"), + ("prog rock", "progressive rock"), + ("progressive rock", "progressive rock"), + ("trad", "traditional folk"), + ("traditional", "traditional folk"), + ], + ) + def test_default_alias_patterns(self, config, input_genre, expected_genre): + """Verify that bundled aliases.yaml correctly handles common variants.""" + plugin = lastgenre.LastGenrePlugin() + result = normalize_genre( + plugin._log, plugin.alias_patterns, input_genre + ) + assert result == expected_genre + + def test_client_normalizes_in_last_lookup(self): + """LastFmClient._last_lookup applies alias normalization then ignorelist.""" + alias_patterns = [(re.compile(r"hip-hop", re.IGNORECASE), "hip hop")] + ignore_patterns = {"*": [re.compile("hip hop", re.IGNORECASE)]} + client = lastgenre.client.LastFmClient( + Mock(), 0, ignore_patterns, alias_patterns + ) + + mock_lastfm_obj = Mock() + mock_lastfm_obj.get_top_tags.return_value = [] + # Seed the cache directly to avoid a real network call. + client.genre_cache["track.artist-title"] = ["hip-hop"] + + result = client._last_lookup("track", Mock(), "artist", "title") + assert result == [], ( + "'hip-hop' must be normalized to 'hip hop' then filtered by ignorelist" + ) From 3ce8c9bbf5e66705d2d634bdb9070db2530dc354 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Fri, 26 Jun 2026 08:32:53 +0200 Subject: [PATCH 5/7] lastgenre: Tests for _filter_valid helper, even though it's indirectly tested via resolve_genres tests and elsewhere, it makes sense to keep them to help with future refactoring. --- test/plugins/test_lastgenre.py | 60 ++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 7e1f1ae732..5c1e19f04e 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -266,6 +266,66 @@ def test_ignorelist_c14n_no_whitelist_drops_ignored_ancestor(self): "ignored oldest ancestor must not appear in the result" ) + # _filter_valid tests + + def test_filter_valid_no_whitelist_no_ignorelist_returns_all(self): + """With neither whitelist nor ignorelist, all non-empty genres pass.""" + self._setup_config(whitelist=False) + self.plugin.ignore_patterns = defaultdict(list) + result = self.plugin._filter_valid(["rock", "jazz", "blues"]) + assert result == ["rock", "jazz", "blues"] + + def test_filter_valid_strips_empty_and_whitespace(self): + """Empty strings and whitespace-only strings are always removed.""" + self._setup_config(whitelist=False) + self.plugin.ignore_patterns = defaultdict(list) + result = self.plugin._filter_valid(["rock", "", " ", "blues"]) + assert result == ["rock", "blues"] + + def test_filter_valid_whitelist_drops_unknown_genres(self): + """Genres not in the whitelist are removed.""" + self._setup_config(whitelist={"rock", "blues"}) + result = self.plugin._filter_valid(["rock", "jazz", "blues"]) + assert result == ["rock", "blues"] + + def test_filter_valid_whitelist_is_case_insensitive(self): + """Whitelist lookup is lowercased, so genre case doesn't matter.""" + self._setup_config(whitelist={"rock"}) + result = self.plugin._filter_valid(["Rock", "ROCK", "rock"]) + assert result == ["Rock", "ROCK", "rock"] + + def test_filter_valid_ignorelist_drops_matched_genres(self): + """Genres matching ignorelist patterns are removed.""" + self._setup_config(whitelist=False) + self.plugin.ignore_patterns = defaultdict( + list, {"*": [re.compile(r"^metal$", re.IGNORECASE)]} + ) + result = self.plugin._filter_valid(["rock", "metal", "jazz"]) + assert result == ["rock", "jazz"] + + def test_filter_valid_ignorelist_artist_specific(self): + """Artist-specific ignorelist patterns apply only for that artist.""" + self._setup_config(whitelist=False) + self.plugin.ignore_patterns = defaultdict( + list, {"the artist": [re.compile(r"^noise$", re.IGNORECASE)]} + ) + assert self.plugin._filter_valid( + ["noise", "rock"], artist="the artist" + ) == ["rock"] + assert self.plugin._filter_valid(["noise", "rock"], artist="other") == [ + "noise", + "rock", + ] + + def test_filter_valid_whitelist_and_ignorelist_combined(self): + """Whitelist is applied first; ignorelist further filters the remainder.""" + self._setup_config(whitelist={"rock", "metal", "jazz"}) + self.plugin.ignore_patterns = defaultdict( + list, {"*": [re.compile(r"^metal$", re.IGNORECASE)]} + ) + result = self.plugin._filter_valid(["rock", "metal", "pop", "jazz"]) + assert result == ["rock", "jazz"] + @pytest.mark.parametrize( "config_values, item_genre, mock_genres, expected_result", From fda3fef1482d327ae2c246edb0248ce38bb946a8 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Wed, 24 Jun 2026 08:02:51 +0200 Subject: [PATCH 6/7] lastgenre: Genre aliases docs; Tools chapter, More - Document the genre alias normalization feature and the default aliases we ship with the plugin (by using rst "literalinclude" referring to aliases.yaml directly) - Clarify what canonicalization acutually does when used without a whitelist enabled. - New docs chapter "Using the right tool" --- docs/plugins/lastgenre.rst | 79 +++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst index db8118ee23..d907aeb95f 100644 --- a/docs/plugins/lastgenre.rst +++ b/docs/plugins/lastgenre.rst @@ -70,6 +70,11 @@ contains about any genre contained in the tree) with canonicalization because nothing would ever be matched to a more generic node since all the specific subgenres are in the whitelist to begin with. +If you use canonicalization *without* a whitelist, the plugin will simply map +every genre to its top-most root category in the tree (e.g., ``Viking Metal`` → +``Rock``). This is a great way to keep your library broad without needing to +maintain a manual list of allowed genres. + .. _tree of nested genre names: https://raw.githubusercontent.com/beetbox/beets/master/beetsplug/lastgenre/genres-tree.yaml .. _yaml: https://yaml.org/ @@ -212,11 +217,78 @@ plain ``metal`` will not match ``heavy metal`` unless you write a regex like double-escape backslashes in unquoted or single-quoted strings (e.g., use ``\w``, not ``\\w``). +Genre Normalization (Aliases) +----------------------------- + +Last.fm tags often contain variant spellings, abbreviations, or inconsistent +formatting (e.g., "hip-hop", "hiphop", and "hip hop"). The normalization feature +uses an ordered list of regular expression aliases to map these variants to a +single canonical name *before* any other filtering or canonicalization takes +place. + +This feature is enabled by default and uses a built-in alias table that covers +many common cases, such as mapping "dnb" to "drum and bass" or "r&b" to "rhythm +and blues". Set ``aliases: no`` to turn it off entirely. + +You can replace these aliases with your own by setting ``aliases`` to an inline +mapping in your configuration. The keys are the canonical genre names (which +support ``\1`` / ``\g`` back-references to regex capture groups) and the +values are lists of regex patterns. + +.. note:: + + The same formatting and quoting rules regarding YAML special characters and + backslashes mentioned in the **Attention** box in the **Genre Ignorelist** + section apply here as well. + +The full default mappings are shown below - copy and paste this into your config +below the ``lastgenre.aliases`` section to customize it: + +.. literalinclude:: ../../beetsplug/lastgenre/aliases.yaml + :language: yaml + +Choosing the Right Tool +----------------------- + +With multiple ways to filter and map genres, here is a quick guide on when to +use what: + +- **Aliases**: Use these first to fix spelling variants and abbreviations (e.g., + ``dnb`` → ``drum and bass``). +- **Ignorelist**: Use this for error correction when Last.fm results are not + accurate, or for precise per-artist or global exclusions (e.g., rejecting + ``Metal`` for specific electronic artists). +- **Canonicalization**: Use this to automatically map specific sub-genres to + broader categories (e.g., ``Grindcore`` → ``Metal``). +- **Whitelist**: Use this to finally limit your library to a predefined set of + genres. When combined with canonicalization, the plugin will try to map a + sub-genre to its closest whitelisted parent. Anything else is dropped. + Configuration ------------- To configure the plugin, make a ``lastgenre:`` section in your configuration -file. The available options are: +file. Default configuration: + +.. code-block:: yaml + + lastgenre: + auto: yes + canonical: no + cleanup_existing: no + count: 1 + fallback: null + force: no + keep_existing: no + min_weight: 10 + prefer_specific: no + source: album + whitelist: yes + title_case: yes + ignorelist: no + aliases: yes + +The available options are: - **auto**: Fetch genres automatically during import. Default: ``yes``. - **canonical**: Use a canonicalization tree. Setting this to ``yes`` will use a @@ -255,6 +327,11 @@ file. The available options are: - **ignorelist**: A mapping of artist names (or the global ``'*'`` key) to lists of genres to exclude. See `Genre Ignorelist`_ for more details. Default: ``no``. +- **aliases**: Controls genre alias normalization. Set to ``yes`` (default) to + use the built-in alias table, ``no`` to disable normalization entirely, or an + inline mapping of canonical genre names to lists of regex patterns to replace + the built-in table with your own. See `Genre Normalization (Aliases)`_ for + details. Running Manually ---------------- From c51f5755f3d5655f4059bbfec73ca3561089f230 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Wed, 24 Jun 2026 07:54:37 +0200 Subject: [PATCH 7/7] lastgenre: Whitelist/Tree fixes; Default aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixes some inconsistencies in spelling in the data files we ship with the plugin as well as adds some new genres to both files that seem to be returned often by last.fm. - For some changes the default aliases feature helps to streamline inconsistencies that come from last.fm already. - The default aliases file that ships with beets - Tests fixes that prove that most of the alias normalization patterns work as they are supposed to. Co-authored-by: Šarūnas Nejus --- beetsplug/lastgenre/aliases.yaml | 72 ++++++++++++++++++++++++++++ beetsplug/lastgenre/genres-tree.yaml | 24 ++++++---- beetsplug/lastgenre/genres.txt | 44 ++++++++++++----- test/plugins/test_lastgenre.py | 35 ++++++++++---- 4 files changed, 146 insertions(+), 29 deletions(-) create mode 100644 beetsplug/lastgenre/aliases.yaml diff --git a/beetsplug/lastgenre/aliases.yaml b/beetsplug/lastgenre/aliases.yaml new file mode 100644 index 0000000000..a76c4f099f --- /dev/null +++ b/beetsplug/lastgenre/aliases.yaml @@ -0,0 +1,72 @@ +# Ampersands / Delimiters +drum and bass: + - d(rum)?[ &n/]*b(ass)? +r&b: + - r(hythm)?[ &n/]*b(lues)? +rock and roll: + - rock[- ‐'&n/]*roll + +# Hyphenation +\1-\2: + - ([ckj])[- ]*(pop) + - (j)[- ]*(rock) + - (post)[- ]*(\w+) + - (g?lo)[- ]*(fi) + - ([pg])[- ]*(funk) + - (synth)[- ]*(pop) +avant-garde: + - avant[- ]*(garde?)? + +# Adding space +\1 \2: + - (nu)[ -]*(disco|jazz|metal|soul) + - (dark)[ -]*(wave) + - (blues|folk|indie|gothic|progressive|alternative|punk)[ -]*(rock) + - (ska)[ -]*(jazz) + - (synth)[- ]*(funk) + - (glitch|hip|trip)y?(?:[ -]*hip)?[ -]*(hop) + +# Removing delimiters +\1\2: + - (synth)[- ]*(wave) + - (psy)[ -]*(trance) + - (chill)[ -]*(out) + +# Old School spellings: old-school death metal, oldschool hip-hop, old-skool-jungle/-hardcore +old school death metal: + - old[ -]*(?:school|skool)[ -]+death[ -]*metal +old school hip hop: + - old[ -]*(?:school|skool)[ -]+hip[ -]*hop +old school \1: + - old[ -]*(?:school|skool)[ -]+(\w+) + +# Terminology / Synonyms +electronic: + - electronic music +world music: + - world +downtempo: + - down[ -]*beat +shoegaze: + - shoegaz(e?r?|ing) +footwork: + - (chicago )?footwork( music)? +juke: + - (chicago )?juke( music| house)? + +# Compound genres +alternative rock: + - alt[ -]*rock +# negative lookahead avoids matching 'gothic metal' +gothic rock: + - goth(?!ic)([ -]*rock)? +# avoids matching 'progressive metal' +progressive rock: + - prog([ -]*rock)? +punk rock: + - punk +# psy folk, psych-pop, psychedelic-rock, psychedelic soul, but not psychedelic (standalone) +psychedelic \1: + - psy(?:ch(?:edelic)?)?[ -]*(folk|pop|rock|soul) +dixieland: + - dixieland[ -]*jazz diff --git a/beetsplug/lastgenre/genres-tree.yaml b/beetsplug/lastgenre/genres-tree.yaml index d7acfbc1f4..d97568b214 100644 --- a/beetsplug/lastgenre/genres-tree.yaml +++ b/beetsplug/lastgenre/genres-tree.yaml @@ -257,6 +257,7 @@ - broken beat - florida breaks - nu skool breaks + - breaks - chiptune: - bitpop - game boy music @@ -268,12 +269,12 @@ - disco polo - euro disco - italo disco - - nu-disco + - nu disco - space disco - downtempo: - acid jazz - balearic beat - - chill out + - chillout - dub music - dubtronica - ethnic electronica @@ -293,7 +294,7 @@ - jungle: - darkside jungle - ragga jungle - - oldschool jungle + - old school jungle - raggacore - sambass - techstep @@ -327,7 +328,7 @@ - indietronica - new rave - space rock - - synthpop + - synth-pop - synthpunk - electronica: - berlin school @@ -358,6 +359,7 @@ - happy hardcore - hardstyle - jumpstyle + - old school hardcore - makina - speedcore - terrorcore @@ -426,6 +428,7 @@ - dub techno - free tekno - ghettotech + - industrial techno - minimal - nortec - schranz @@ -437,9 +440,10 @@ - acid trance - classic trance - dream trance - - goa trance: + - psytrance: - dark psytrance - full on + - goa trance - psybreaks - psyprog - suomisaundi @@ -492,8 +496,8 @@ - gangsta rap - glitch hop - golden age hip hop + - hardcore hip hop - hip hop soul - - hip pop - hyphy - industrial hip hop - instrumental hip hop @@ -620,11 +624,13 @@ - turkish pop - vispop - wonky pop -- rhythm and blues: +- r&b: - funk: - deep funk - go-go - p-funk + - synth funk + - minneapolis sound - soul: - blue-eyed soul - neo soul @@ -670,6 +676,7 @@ - death/doom - goregrind - melodic death metal + - old school death metal - technical death metal - doom metal: - epic doom metal @@ -708,6 +715,7 @@ - traditional heavy metal - math rock - new wave: + - synthwave - world fusion - paisley underground - pop rock @@ -786,7 +794,7 @@ - chanson - canción de autor - nueva canción -- world: +- world music: - world dub - world fusion - worldbeat diff --git a/beetsplug/lastgenre/genres.txt b/beetsplug/lastgenre/genres.txt index 571b6f3500..c13bf5399d 100644 --- a/beetsplug/lastgenre/genres.txt +++ b/beetsplug/lastgenre/genres.txt @@ -1,5 +1,5 @@ 2 tone -2-step garage +2-step 4-beat 4x4 garage 8-bit @@ -12,10 +12,12 @@ acid rock acoustic music acousticana adult contemporary music +african african popular music african rumba afrobeat aleatoric music +alternative alternative country alternative dance alternative hip hop @@ -112,7 +114,7 @@ blue-eyed soul bluegrass blues blues ballad -blues-rock +blues rock boogie boogie woogie boogie-woogie @@ -123,6 +125,7 @@ brazilian jazz breakbeat breakbeat hardcore breakcore +breaks breton music brill building pop britfunk @@ -227,6 +230,7 @@ chicano rap chicha chicken scratch children's music +chill chillout chillwave chimurenga @@ -234,6 +238,7 @@ chinese music chinese pop chinese rock chip music +chiptune cho-kantrum chongak chopera @@ -336,7 +341,6 @@ dabka dadra daina dalauna -dance dance music dance-pop dance-punk @@ -349,10 +353,10 @@ danza danzón dark ambient dark cabaret +dark wave dark pop darkcore darkstep -darkwave de ascultat la servici de codru de dragoste @@ -395,7 +399,6 @@ disney pop diva house divine rock dixieland -dixieland jazz djambadon djent dodompa @@ -415,6 +418,7 @@ drone metal drone music dronology drum and bass +drumfunk dub dub house dubanguthu @@ -497,6 +501,7 @@ folk pop folk punk folk rock folktronica +footwork forró franco-country freak-folk @@ -518,6 +523,7 @@ full on funaná funeral doom funk +funk / soul funk metal funk rock funkcore @@ -679,6 +685,7 @@ impressionist music improvisational incidental music indian pop +indie indie folk indie music indie pop @@ -688,12 +695,13 @@ indo jazz indo rock indonesian pop indoyíftika +industrial industrial death metal industrial hip hop industrial metal -industrial music industrial musical industrial rock +industrial techno instrumental rock intelligent dance music international latin @@ -755,6 +763,8 @@ jtek jug band jujitsu juju +juke +juke/footwork juke joint blues jump blues jumpstyle @@ -968,6 +978,7 @@ min'yo mineras mini compas mini-jazz +minimal minimal techno minimalist music minimalist trance @@ -1063,7 +1074,7 @@ nintendocore nisiótika no wave noh -noise music +noise noise pop noise rock nongak @@ -1074,6 +1085,7 @@ nortec norteño northern soul nota +nu disco nu jazz nu metal nu soul @@ -1083,7 +1095,10 @@ nyatiti néo kýma obscuro oi! +old school death metal +old school hardcore old school hip hop +old school jungle old-time oldies olonkho @@ -1190,11 +1205,13 @@ progressive trance progressive thrash metal protopunk psych folk +psychedelic folk psychedelic music psychedelic pop psychedelic rock -psychedelic trance +psychedelic soul psychobilly +psytrance punk blues punk cabaret punk jazz @@ -1249,8 +1266,7 @@ renaissance music requiem rhapsody rhyming spiritual -rhythm & blues -rhythm and blues +r&b ricercar riot grrrl rock @@ -1314,6 +1330,7 @@ shabad shalako shan'ge shango +shangaan electro shape note shibuya-kei shidaiqu @@ -1328,8 +1345,10 @@ sica siguiriyas silat sinawi +singer-songwriter situational ska +ska jazz ska punk skacore skald @@ -1406,9 +1425,11 @@ symphonic metal symphonic poem symphonic rock symphony +synth-pop +synth funk synthcore -synthpop synthpunk +synthwave t'ong guitar taarab tai tu @@ -1454,6 +1475,7 @@ tinga tinku toeshey togaku +trad trad jazz traditional bluegrass traditional heavy metal diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 5c1e19f04e..32eca9294d 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -1039,17 +1039,20 @@ def test_disabled(self, config): [ ("dnb", "drum and bass"), ("drum n bass", "drum and bass"), - ("r&b", "rhythm and blues"), - ("rnb", "rhythm and blues"), + ("r&b", "r&b"), + ("rnb", "r&b"), ("rock & roll", "rock and roll"), ("rock'n'roll", "rock and roll"), ("kpop", "k-pop"), + ("k -pop", "k-pop"), ("j rock", "j-rock"), ("post rock", "post-rock"), ("lofi", "lo-fi"), ("lo fi", "lo-fi"), ("p funk", "p-funk"), - ("synth pop", "synthpop"), + ("synth funk", "synth funk"), + ("synth pop", "synth-pop"), + ("synth-wave", "synthwave"), ("avantgarde", "avant-garde"), ("avant gard", "avant-garde"), ("nu-jazz", "nu jazz"), @@ -1059,11 +1062,11 @@ def test_disabled(self, config): ("nu - disco", "nu disco"), ("electronic music", "electronic"), ("world", "world music"), - ("chill", "chillout"), + ("chill", "chill"), ("chill out", "chillout"), ("chill-out", "chillout"), - ("dark wave", "darkwave"), - ("dark-wave", "darkwave"), + ("dark wave", "dark wave"), + ("dark-wave", "dark wave"), ("blues rock", "blues rock"), ("blues-rock", "blues rock"), ("folk-rock", "folk rock"), @@ -1072,17 +1075,29 @@ def test_disabled(self, config): ("shoegazing", "shoegaze"), ("hip-hop", "hip hop"), ("triphop", "trip hop"), - ("alt", "alternative rock"), + ("punk", "punk rock"), + ("alt", "alt"), ("alt rock", "alternative rock"), - ("alternative", "alternative rock"), + ("alternative", "alternative"), ("goth", "gothic rock"), ("goth rock", "gothic rock"), ("gothic rock", "gothic rock"), ("prog", "progressive rock"), ("prog rock", "progressive rock"), ("progressive rock", "progressive rock"), - ("trad", "traditional folk"), - ("traditional", "traditional folk"), + ("trad", "trad"), + ("traditional", "traditional"), + ("indie", "indie"), + ("indie rock", "indie rock"), + ("juke/footwork", "juke/footwork"), + ("Funk / Soul", "funk / soul"), + ("psy-trance", "psytrance"), + ("psy rock", "psychedelic rock"), + ("psych-pop", "psychedelic pop"), + ("disney", "disney"), + ("old-school death-metal", "old school death metal"), + ("old-skool-hip-hop", "old school hip hop"), + ("old school-jungle", "old school jungle"), ], ) def test_default_alias_patterns(self, config, input_genre, expected_genre):