diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index bd245dcf0..71355223b 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -22,7 +22,7 @@ import yaml 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 @@ if TYPE_CHECKING: 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 @@ class LastGenrePlugin(plugins.BeetsPlugin): "title_case": True, "pretend": False, "ignorelist": {}, + "aliases": True, } ) self.setup() @@ -132,9 +134,15 @@ class LastGenrePlugin(plugins.BeetsPlugin): 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 @@ class LastGenrePlugin(plugins.BeetsPlugin): 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 @@ class LastGenrePlugin(plugins.BeetsPlugin): 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 @@ class LastGenrePlugin(plugins.BeetsPlugin): [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 @@ class LastGenrePlugin(plugins.BeetsPlugin): """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 @@ class LastGenrePlugin(plugins.BeetsPlugin): 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 @@ class LastGenrePlugin(plugins.BeetsPlugin): ) -> 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 25f9a5459..412613c1a 100644 --- a/beetsplug/lastgenre/client.py +++ b/beetsplug/lastgenre/client.py @@ -9,7 +9,7 @@ import pylast 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 @@ if TYPE_CHECKING: 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 @@ class LastFmClient: self, log: BeetsLogger, min_weight: int, - ignore_patterns: GenreIgnorePatterns, + ignore_patterns: IgnorePatternsByArtist, + alias_patterns: list[AliasPatternWithReplacement], ): """Initialize the client. @@ -61,7 +62,8 @@ class LastFmClient: """ 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 @@ class LastFmClient: "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 60fc8853e..8d29dcd4a 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