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/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/client.py b/beetsplug/lastgenre/client.py index 25f9a54590..edcfe1224f 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,17 +52,19 @@ def __init__( self, log: BeetsLogger, min_weight: int, - ignore_patterns: GenreIgnorePatterns, + ignore_patterns: IgnorePatternsByArtist, + alias_patterns: list[AliasPatternWithReplacement], ): """Initialize the client. 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: GenreIgnorePatterns = ignore_patterns - self._genre_cache: GenreCache = {} + 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 = {} def fetch_genres( self, obj: pylast.Album | pylast.Artist | pylast.Track @@ -71,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. @@ -103,19 +105,21 @@ 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. # 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/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/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 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) ---------------------- 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 ---------------- diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index ea1794116e..32eca9294d 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"] @@ -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", @@ -722,14 +782,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 +809,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 +828,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 +847,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 +913,215 @@ 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", "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 funk", "synth funk"), + ("synth pop", "synth-pop"), + ("synth-wave", "synthwave"), + ("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", "chill"), + ("chill out", "chillout"), + ("chill-out", "chillout"), + ("dark wave", "dark wave"), + ("dark-wave", "dark wave"), + ("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"), + ("punk", "punk rock"), + ("alt", "alt"), + ("alt rock", "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", "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): + """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" + )