mirror of
https://github.com/beetbox/beets.git
synced 2026-07-16 17:48:54 -04:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user