lastgenre: Genre aliases; Refactoring

- Genre alias normalization feature implementation
- Extend and simplify _filter_valid()
- Remove drop_ignored_genres()
- More smaller ignorelist related refactoring
This commit is contained in:
J0J0 Todos
2026-06-24 07:33:09 +02:00
parent a78c94c443
commit 4b9fa31a5b
3 changed files with 139 additions and 45 deletions

View File

@@ -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<N>`` 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.

View File

@@ -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.

View File

@@ -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<N>``
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