From c60a32a471b724e0ce58c46c0adbb517ce95587a Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 1 Feb 2026 23:36:42 +0100 Subject: [PATCH 01/11] lastgenre: Move fetching to client module and tunelog helper to a utils module since we will need it everywhere. --- beetsplug/lastgenre/__init__.py | 143 ++++++-------------------------- beetsplug/lastgenre/client.py | 141 +++++++++++++++++++++++++++++++ beetsplug/lastgenre/utils.py | 31 +++++++ 3 files changed, 197 insertions(+), 118 deletions(-) create mode 100644 beetsplug/lastgenre/client.py create mode 100644 beetsplug/lastgenre/utils.py diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 1c91688a69..f75fa03127 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -25,32 +25,24 @@ from __future__ import annotations import os -import traceback from functools import singledispatchmethod from pathlib import Path from typing import TYPE_CHECKING, Any -import pylast import yaml from beets import config, library, plugins, ui from beets.library import Album, Item from beets.util import plurality, unique_list +from .client import LastFmClient +from .utils import tunelog + if TYPE_CHECKING: import optparse - from collections.abc import Callable from beets.library import LibModel -LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY) - -PYLAST_EXCEPTIONS = ( - pylast.WSError, - pylast.MalformedResponseError, - pylast.NetworkError, -) - # Canonicalization tree processing. @@ -123,7 +115,9 @@ def setup(self) -> None: if self.config["auto"]: self.import_stages = [self.imported] - self._genre_cache: dict[str, list[str]] = {} + self.client = LastFmClient( + self._log, self.config["min_weight"].get(int) + ) self.whitelist = self._load_whitelist() self.c14n_branches, self.canonicalize = self._load_c14n_tree() @@ -168,11 +162,6 @@ def _load_c14n_tree(self) -> tuple[list[list[str]], bool]: flatten_tree(genres_tree, [], c14n_branches) return c14n_branches, canonicalize - def _tunelog(self, msg: str, *args: Any, **kwargs: Any) -> None: - """Log tuning messages at DEBUG level when verbosity level is high enough.""" - if config["verbose"].as_number() >= 3: - self._log.debug(msg, *args, **kwargs) - @property def sources(self) -> tuple[str, ...]: """A tuple of allowed genre sources. May contain 'track', @@ -266,15 +255,6 @@ def _resolve_genres(self, tags: list[str]) -> list[str]: valid_tags = [t for t in tags if self._is_valid(t)] return valid_tags[:count] - def fetch_genre( - self, lastfm_obj: pylast.Album | pylast.Artist | pylast.Track - ) -> list[str]: - """Return genres for a pylast entity. Returns an empty list if - no suitable genres are found. - """ - min_weight = self.config["min_weight"].get(int) - return self._tags_for(lastfm_obj, min_weight) - def _is_valid(self, genre: str) -> bool: """Check if the genre is valid. @@ -285,48 +265,6 @@ def _is_valid(self, genre: str) -> bool: return True return False - # Cached last.fm entity lookups. - - def _last_lookup( - self, entity: str, method: Callable[..., Any], *args: str - ) -> list[str]: - """Get genres based on the named entity using the callable `method` - whose arguments are given in the sequence `args`. The genre lookup - is cached based on the entity name and the arguments. - - Before the lookup, each argument has the "-" Unicode character replaced - with its rough ASCII equivalents in order to return better results from - the Last.fm database. - """ - # Shortcut if we're missing metadata. - if any(not s for s in args): - return [] - - key = f"{entity}.{'-'.join(str(a) for a in args)}" - if key not in self._genre_cache: - args_replaced = [a.replace("\u2010", "-") for a in args] - self._genre_cache[key] = self.fetch_genre(method(*args_replaced)) - - genre = self._genre_cache[key] - self._tunelog("last.fm (unfiltered) {} tags: {}", entity, genre) - return genre - - def fetch_album_genre(self, albumartist: str, albumtitle: str) -> list[str]: - """Return genres from Last.fm for the album by albumartist.""" - return self._last_lookup( - "album", LASTFM.get_album, albumartist, albumtitle - ) - - def fetch_artist_genre(self, artist: str) -> list[str]: - """Return genres from Last.fm for the artist.""" - return self._last_lookup("artist", LASTFM.get_artist, artist) - - def fetch_track_genre(self, trackartist: str, tracktitle: str) -> list[str]: - """Return genres from Last.fm for the track by artist.""" - return self._last_lookup( - "track", LASTFM.get_track, trackartist, tracktitle - ) - # Main processing: _get_genre() and helpers. def _format_and_stringify(self, tags: list[str]) -> str: @@ -414,14 +352,18 @@ def _try_resolve_stage( # Run through stages: track, album, artist, # album artist, or most popular track genre. if isinstance(obj, library.Item) and "track" in self.sources: - if new_genres := self.fetch_track_genre(obj.artist, obj.title): + if new_genres := self.client.fetch_track_genre( + obj.artist, obj.title + ): if result := _try_resolve_stage( "track", keep_genres, new_genres ): return result if "album" in self.sources: - if new_genres := self.fetch_album_genre(obj.albumartist, obj.album): + if new_genres := self.client.fetch_album_genre( + obj.albumartist, obj.album + ): if result := _try_resolve_stage( "album", keep_genres, new_genres ): @@ -430,22 +372,27 @@ def _try_resolve_stage( if "artist" in self.sources: new_genres = [] if isinstance(obj, library.Item): - new_genres = self.fetch_artist_genre(obj.artist) + new_genres = self.client.fetch_artist_genre(obj.artist) stage_label = "artist" elif obj.albumartist != config["va_name"].as_str(): - new_genres = self.fetch_artist_genre(obj.albumartist) + new_genres = self.client.fetch_artist_genre(obj.albumartist) stage_label = "album artist" if not new_genres: - self._tunelog( + tunelog( + self._log, 'No album artist genre found for "{}", ' "trying multi-valued field...", obj.albumartist, ) for albumartist in obj.albumartists: - self._tunelog( - 'Fetching artist genre for "{}"', albumartist + tunelog( + self._log, + 'Fetching artist genre for "{}"', + albumartist, + ) + new_genres += self.client.fetch_artist_genre( + albumartist ) - new_genres += self.fetch_artist_genre(albumartist) if new_genres: stage_label = "multi-valued album artist" else: @@ -455,11 +402,11 @@ def _try_resolve_stage( for item in obj.items(): item_genre = None if "track" in self.sources: - item_genre = self.fetch_track_genre( + item_genre = self.client.fetch_track_genre( item.artist, item.title ) if not item_genre: - item_genre = self.fetch_artist_genre(item.artist) + item_genre = self.client.fetch_artist_genre(item.artist) if item_genre: item_genres += item_genre if item_genres: @@ -607,43 +554,3 @@ def imported( self, session: library.Session, task: library.ImportTask ) -> None: self._process(task.album if task.is_album else task.item, write=False) - - def _tags_for( - self, - obj: pylast.Album | pylast.Artist | pylast.Track, - min_weight: int | None = None, - ) -> list[str]: - """Core genre identification routine. - - Given a pylast entity (album or track), return a list of - tag names for that entity. Return an empty list if the entity is - not found or another error occurs. - - If `min_weight` is specified, tags are filtered by weight. - """ - # Work around an inconsistency in pylast where - # Album.get_top_tags() does not return TopItem instances. - # https://github.com/pylast/pylast/issues/86 - obj_to_query: Any = obj - if isinstance(obj, pylast.Album): - obj_to_query = super(pylast.Album, obj) - - try: - res: Any = obj_to_query.get_top_tags() - except PYLAST_EXCEPTIONS as 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) - return [] - - # Filter by weight (optionally). - if min_weight: - res = [el for el in res if (int(el.weight or 0)) >= min_weight] - - # Get strings from tags. - tags: list[str] = [el.item.get_name().lower() for el in res] - - return tags diff --git a/beetsplug/lastgenre/client.py b/beetsplug/lastgenre/client.py new file mode 100644 index 0000000000..b7444cae8a --- /dev/null +++ b/beetsplug/lastgenre/client.py @@ -0,0 +1,141 @@ +# This file is part of beets. +# Copyright 2016, Adrian Sampson. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + + +"""Last.fm API client for genre lookups.""" + +from __future__ import annotations + +import traceback +from typing import TYPE_CHECKING, Any + +import pylast + +from beets import plugins + +from .utils import tunelog + +if TYPE_CHECKING: + from collections.abc import Callable + + from beets.logging import Logger + +LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY) + +PYLAST_EXCEPTIONS = ( + pylast.WSError, + pylast.MalformedResponseError, + pylast.NetworkError, +) + + +class LastFmClient: + """Client for fetching genres from Last.fm.""" + + def __init__(self, log: Logger, min_weight: int): + """Initialize the client. + + The min_weight parameter filters tags by their minimum weight. + """ + self._log = log + self._min_weight = min_weight + self._genre_cache: dict[str, list[str]] = {} + + def fetch_genre( + self, lastfm_obj: pylast.Album | pylast.Artist | pylast.Track + ) -> list[str]: + """Return genres for a pylast entity. Returns an empty list if + no suitable genres are found. + """ + return self._tags_for(lastfm_obj, self._min_weight) + + def _tags_for( + self, + obj: pylast.Album | pylast.Artist | pylast.Track, + min_weight: int | None = None, + ) -> list[str]: + """Core genre identification routine. + + Given a pylast entity (album or track), return a list of + tag names for that entity. Return an empty list if the entity is + not found or another error occurs. + + If `min_weight` is specified, tags are filtered by weight. + """ + # Work around an inconsistency in pylast where + # Album.get_top_tags() does not return TopItem instances. + # https://github.com/pylast/pylast/issues/86 + obj_to_query: Any = obj + if isinstance(obj, pylast.Album): + obj_to_query = super(pylast.Album, obj) + + try: + res: Any = obj_to_query.get_top_tags() + except PYLAST_EXCEPTIONS as 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) + return [] + + # Filter by weight (optionally). + if min_weight: + res = [el for el in res if (int(el.weight or 0)) >= min_weight] + + # Get strings from tags. + tags: list[str] = [el.item.get_name().lower() for el in res] + + return tags + + def _last_lookup( + self, entity: str, method: Callable[..., Any], *args: str + ) -> list[str]: + """Get genres based on the named entity using the callable `method` + whose arguments are given in the sequence `args`. The genre lookup + is cached based on the entity name and the arguments. + + Before the lookup, each argument has the "-" Unicode character replaced + with its rough ASCII equivalents in order to return better results from + the Last.fm database. + """ + # Shortcut if we're missing metadata. + if any(not s for s in args): + return [] + + key = f"{entity}.{'-'.join(str(a) for a in args)}" + if key not in self._genre_cache: + args_replaced = [a.replace("\u2010", "-") for a in args] + self._genre_cache[key] = self.fetch_genre(method(*args_replaced)) + + genre = self._genre_cache[key] + tunelog(self._log, "last.fm (unfiltered) {} tags: {}", entity, genre) + return genre + + def fetch_album_genre(self, albumartist: str, albumtitle: str) -> list[str]: + """Return genres from Last.fm for the album by albumartist.""" + return self._last_lookup( + "album", LASTFM.get_album, albumartist, albumtitle + ) + + def fetch_artist_genre(self, artist: str) -> list[str]: + """Return genres from Last.fm for the artist.""" + return self._last_lookup("artist", LASTFM.get_artist, artist) + + def fetch_track_genre(self, trackartist: str, tracktitle: str) -> list[str]: + """Return genres from Last.fm for the track by artist.""" + return self._last_lookup( + "track", LASTFM.get_track, trackartist, tracktitle + ) diff --git a/beetsplug/lastgenre/utils.py b/beetsplug/lastgenre/utils.py new file mode 100644 index 0000000000..4c7846f238 --- /dev/null +++ b/beetsplug/lastgenre/utils.py @@ -0,0 +1,31 @@ +# This file is part of beets. +# Copyright 2026, J0J0 Todos. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + + +"""Shared utility functions for the lastgenre plugin.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from beets import config + +if TYPE_CHECKING: + from beets.logging import Logger + + +def tunelog(log: Logger, msg: str, *args: Any, **kwargs: Any) -> None: + """Log tuning messages at DEBUG level when verbosity level is high enough.""" + if config["verbose"].as_number() >= 3: + log.debug(msg, *args, **kwargs) From e2613c314ea9fd0311d64dce90b7663ad5941d7a Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sat, 7 Feb 2026 17:24:41 +0100 Subject: [PATCH 02/11] lastgenre: tunelog better --- beetsplug/lastgenre/__init__.py | 9 ++++----- beetsplug/lastgenre/client.py | 5 +++-- beetsplug/lastgenre/utils.py | 18 ++++++++++++++---- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index f75fa03127..cec41bffa4 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -36,7 +36,7 @@ from beets.util import plurality, unique_list from .client import LastFmClient -from .utils import tunelog +from .utils import make_tunelog if TYPE_CHECKING: import optparse @@ -115,6 +115,7 @@ def setup(self) -> None: if self.config["auto"]: self.import_stages = [self.imported] + self._tunelog = make_tunelog(self._log) self.client = LastFmClient( self._log, self.config["min_weight"].get(int) ) @@ -378,15 +379,13 @@ def _try_resolve_stage( new_genres = self.client.fetch_artist_genre(obj.albumartist) stage_label = "album artist" if not new_genres: - tunelog( - self._log, + self._tunelog( 'No album artist genre found for "{}", ' "trying multi-valued field...", obj.albumartist, ) for albumartist in obj.albumartists: - tunelog( - self._log, + self._tunelog( 'Fetching artist genre for "{}"', albumartist, ) diff --git a/beetsplug/lastgenre/client.py b/beetsplug/lastgenre/client.py index b7444cae8a..21a0bff727 100644 --- a/beetsplug/lastgenre/client.py +++ b/beetsplug/lastgenre/client.py @@ -24,7 +24,7 @@ from beets import plugins -from .utils import tunelog +from .utils import make_tunelog if TYPE_CHECKING: from collections.abc import Callable @@ -49,6 +49,7 @@ def __init__(self, log: Logger, min_weight: int): The min_weight parameter filters tags by their minimum weight. """ self._log = log + self._tunelog = make_tunelog(log) self._min_weight = min_weight self._genre_cache: dict[str, list[str]] = {} @@ -121,7 +122,7 @@ def _last_lookup( self._genre_cache[key] = self.fetch_genre(method(*args_replaced)) genre = self._genre_cache[key] - tunelog(self._log, "last.fm (unfiltered) {} tags: {}", entity, genre) + self._tunelog("last.fm (unfiltered) {} tags: {}", entity, genre) return genre def fetch_album_genre(self, albumartist: str, albumtitle: str) -> list[str]: diff --git a/beetsplug/lastgenre/utils.py b/beetsplug/lastgenre/utils.py index 4c7846f238..7ae96e11a7 100644 --- a/beetsplug/lastgenre/utils.py +++ b/beetsplug/lastgenre/utils.py @@ -22,10 +22,20 @@ from beets import config if TYPE_CHECKING: + from collections.abc import Callable + from beets.logging import Logger -def tunelog(log: Logger, msg: str, *args: Any, **kwargs: Any) -> None: - """Log tuning messages at DEBUG level when verbosity level is high enough.""" - if config["verbose"].as_number() >= 3: - log.debug(msg, *args, **kwargs) +def make_tunelog(log: Logger) -> Callable[..., None]: + """Create a tunelog function bound to a specific logger. + + Returns a callable that logs tuning messages at DEBUG level when + verbosity is high enough. + """ + + def tunelog(msg: str, *args: Any, **kwargs: Any) -> None: + if config["verbose"].as_number() >= 3: + log.debug(msg, *args, **kwargs) + + return tunelog From 99dd4885e3394227ad6c629ae818b6a85a0b4a90 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sat, 7 Feb 2026 18:46:52 +0100 Subject: [PATCH 03/11] lastgenre: File loaders module --- beetsplug/lastgenre/__init__.py | 58 ++------------ beetsplug/lastgenre/loaders.py | 133 ++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 51 deletions(-) create mode 100644 beetsplug/lastgenre/loaders.py diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index cec41bffa4..72647f15c9 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -24,18 +24,16 @@ from __future__ import annotations -import os from functools import singledispatchmethod from pathlib import Path from typing import TYPE_CHECKING, Any -import yaml - from beets import config, library, plugins, ui from beets.library import Album, Item from beets.util import plurality, unique_list from .client import LastFmClient +from .loaders import DataFileLoader from .utils import make_tunelog if TYPE_CHECKING: @@ -81,12 +79,6 @@ def find_parents(candidate: str, branches: list[list[str]]) -> list[str]: return [candidate] -# Main plugin logic. - -WHITELIST = os.path.join(os.path.dirname(__file__), "genres.txt") -C14N_TREE = os.path.join(os.path.dirname(__file__), "genres-tree.yaml") - - class LastGenrePlugin(plugins.BeetsPlugin): def __init__(self) -> None: super().__init__() @@ -119,49 +111,13 @@ def setup(self) -> None: self.client = LastFmClient( self._log, self.config["min_weight"].get(int) ) - self.whitelist = self._load_whitelist() - self.c14n_branches, self.canonicalize = self._load_c14n_tree() - def _load_whitelist(self) -> set[str]: - """Load the whitelist from a text file. - - Default whitelist is used if config is True, empty string or set to "nothing". - """ - whitelist = set() - wl_filename = self.config["whitelist"].get() - if wl_filename in (True, "", None): # Indicates the default whitelist. - wl_filename = WHITELIST - if wl_filename: - self._log.debug("Loading whitelist {}", wl_filename) - text = Path(wl_filename).expanduser().read_text(encoding="utf-8") - for line in text.splitlines(): - if (line := line.strip().lower()) and not line.startswith("#"): - whitelist.add(line) - - return whitelist - - def _load_c14n_tree(self) -> tuple[list[list[str]], bool]: - """Load the canonicalization tree from a YAML file. - - Default tree is used if config is True, empty string, set to "nothing" - or if prefer_specific is enabled. - """ - c14n_branches: list[list[str]] = [] - c14n_filename = self.config["canonical"].get() - canonicalize = c14n_filename is not False - # Default tree - if c14n_filename in (True, "", None) or ( - # prefer_specific requires a tree, load default tree - not canonicalize and self.config["prefer_specific"].get() - ): - c14n_filename = C14N_TREE - # Read the tree - if c14n_filename: - self._log.debug("Loading canonicalization tree {}", c14n_filename) - with Path(c14n_filename).expanduser().open(encoding="utf-8") as f: - genres_tree = yaml.safe_load(f) - flatten_tree(genres_tree, [], c14n_branches) - return c14n_branches, canonicalize + loader = DataFileLoader.from_config( + self.config, self._log, Path(__file__).parent, flatten_tree + ) + self.whitelist = loader.whitelist + self.c14n_branches = loader.c14n_branches + self.canonicalize = loader.canonicalize @property def sources(self) -> tuple[str, ...]: diff --git a/beetsplug/lastgenre/loaders.py b/beetsplug/lastgenre/loaders.py new file mode 100644 index 0000000000..de71fb94d1 --- /dev/null +++ b/beetsplug/lastgenre/loaders.py @@ -0,0 +1,133 @@ +# This file is part of beets. +# Copyright 2016, Adrian Sampson. +# Copyright 2026, J0J0 Todos. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + + +"""Data file loaders for the lastgenre plugin.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import yaml + +if TYPE_CHECKING: + from confuse import ConfigView + + from beets.logging import Logger + + +class DataFileLoader: + """Loads genre-related data files for the lastgenre plugin.""" + + def __init__( + self, + log: Logger, + plugin_dir: Path, + whitelist: set[str], + c14n_branches: list[list[str]], + canonicalize: bool, + ): + """Initialize with pre-loaded data. + + Use from_config() classmethod to construct from plugin config. + """ + self._log = log + self._plugin_dir = plugin_dir + self.whitelist = whitelist + self.c14n_branches = c14n_branches + self.canonicalize = canonicalize + + @classmethod + def from_config( + cls, + config: ConfigView, + log: Logger, + plugin_dir: Path, + flatten_func: Callable, + ) -> DataFileLoader: + """Create a DataFileLoader from plugin configuration. + + Reads config values and loads all data files during construction. + """ + # Default paths + default_whitelist = str(plugin_dir / "genres.txt") + default_tree = str(plugin_dir / "genres-tree.yaml") + + # Load whitelist + whitelist = cls._load_whitelist( + log, config["whitelist"].get(), default_whitelist + ) + + # Load tree + c14n_branches, canonicalize = cls._load_tree( + log, + config["canonical"].get(), + default_tree, + config["prefer_specific"].get(), + flatten_func, + ) + + return cls(log, plugin_dir, whitelist, c14n_branches, canonicalize) + + @staticmethod + def _load_whitelist( + log: Logger, config_value: str | bool | None, default_path: str + ) -> set[str]: + """Load the whitelist from a text file. + + Returns set of valid genre names (lowercase). + """ + whitelist = set() + wl_filename = config_value + if wl_filename in (True, "", None): # Indicates the default whitelist. + wl_filename = default_path + if wl_filename: + log.debug("Loading whitelist {}", wl_filename) + text = Path(wl_filename).expanduser().read_text(encoding="utf-8") + for line in text.splitlines(): + if (line := line.strip().lower()) and not line.startswith("#"): + whitelist.add(line) + + return whitelist + + @staticmethod + def _load_tree( + log: Logger, + config_value: str | bool | None, + default_path: str, + prefer_specific: bool, + flatten_func: Callable, + ) -> tuple[list[list[str]], bool]: + """Load the canonicalization tree from a YAML file. + + Returns tuple of (branches, canonicalize_enabled). + """ + c14n_branches: list[list[str]] = [] + c14n_filename = config_value + canonicalize = c14n_filename is not False + # Default tree + if c14n_filename in (True, "", None) or ( + # prefer_specific requires a tree, load default tree + not canonicalize and prefer_specific + ): + c14n_filename = default_path + # Read the tree + if c14n_filename: + log.debug("Loading canonicalization tree {}", c14n_filename) + with Path(c14n_filename).expanduser().open(encoding="utf-8") as f: + genres_tree = yaml.safe_load(f) + flatten_func(genres_tree, [], c14n_branches) + return c14n_branches, canonicalize From b81f1cf056feb4740ef426a0b7de4cf91b007524 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sat, 7 Feb 2026 20:06:01 +0100 Subject: [PATCH 04/11] lastgenre: Move tree helpers to where they are used --- beetsplug/lastgenre/__init__.py | 58 +++++++++------------------------ beetsplug/lastgenre/loaders.py | 24 +++++++++++--- 2 files changed, 36 insertions(+), 46 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 72647f15c9..388962b651 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -26,7 +26,7 @@ from functools import singledispatchmethod from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from beets import config, library, plugins, ui from beets.library import Album, Item @@ -42,43 +42,6 @@ from beets.library import LibModel -# Canonicalization tree processing. - - -def flatten_tree( - elem: dict[Any, Any] | list[Any] | str, - path: list[str], - branches: list[list[str]], -) -> None: - """Flatten nested lists/dictionaries into lists of strings - (branches). - """ - if not path: - path = [] - - if isinstance(elem, dict): - for k, v in elem.items(): - flatten_tree(v, [*path, k], branches) - elif isinstance(elem, list): - for sub in elem: - flatten_tree(sub, path, branches) - else: - branches.append([*path, str(elem)]) - - -def find_parents(candidate: str, branches: list[list[str]]) -> list[str]: - """Find parents genre of a given genre, ordered from the closest to - the further parent. - """ - for branch in branches: - try: - idx = branch.index(candidate.lower()) - return list(reversed(branch[: idx + 1])) - except ValueError: - continue - return [candidate] - - class LastGenrePlugin(plugins.BeetsPlugin): def __init__(self) -> None: super().__init__() @@ -113,7 +76,7 @@ def setup(self) -> None: ) loader = DataFileLoader.from_config( - self.config, self._log, Path(__file__).parent, flatten_tree + self.config, self._log, Path(__file__).parent ) self.whitelist = loader.whitelist self.c14n_branches = loader.c14n_branches @@ -133,7 +96,7 @@ def sources(self) -> tuple[str, ...]: return ("artist",) return tuple() - # More canonicalization and general helpers. + # Canonicalization and filtering. def _get_depth(self, tag: str) -> int | None: """Find the depth of a tag in the genres tree.""" @@ -153,6 +116,17 @@ def _sort_by_depth(self, tags: list[str]) -> list[str]: depth_tag_pairs.sort(reverse=True) return [p[1] for p in depth_tag_pairs] + @staticmethod + def find_parents(candidate: str, branches: list[list[str]]) -> list[str]: + """Find parent genres of a given genre, ordered from closest to furthest.""" + for branch in branches: + try: + idx = branch.index(candidate.lower()) + return list(reversed(branch[: idx + 1])) + except ValueError: + continue + return [candidate] + def _resolve_genres(self, tags: list[str]) -> list[str]: """Canonicalize, sort and filter a list of genres. @@ -185,11 +159,11 @@ def _resolve_genres(self, tags: list[str]) -> list[str]: if self.whitelist: parents = [ x - for x in find_parents(tag, self.c14n_branches) + for x in self.find_parents(tag, self.c14n_branches) if self._is_valid(x) ] else: - parents = [find_parents(tag, self.c14n_branches)[-1]] + parents = [self.find_parents(tag, self.c14n_branches)[-1]] tags_all += parents # Stop if we have enough tags already, unless we need to find diff --git a/beetsplug/lastgenre/loaders.py b/beetsplug/lastgenre/loaders.py index de71fb94d1..3d650bd5e3 100644 --- a/beetsplug/lastgenre/loaders.py +++ b/beetsplug/lastgenre/loaders.py @@ -56,7 +56,6 @@ def from_config( config: ConfigView, log: Logger, plugin_dir: Path, - flatten_func: Callable, ) -> DataFileLoader: """Create a DataFileLoader from plugin configuration. @@ -77,7 +76,6 @@ def from_config( config["canonical"].get(), default_tree, config["prefer_specific"].get(), - flatten_func, ) return cls(log, plugin_dir, whitelist, c14n_branches, canonicalize) @@ -109,7 +107,6 @@ def _load_tree( config_value: str | bool | None, default_path: str, prefer_specific: bool, - flatten_func: Callable, ) -> tuple[list[list[str]], bool]: """Load the canonicalization tree from a YAML file. @@ -129,5 +126,24 @@ def _load_tree( log.debug("Loading canonicalization tree {}", c14n_filename) with Path(c14n_filename).expanduser().open(encoding="utf-8") as f: genres_tree = yaml.safe_load(f) - flatten_func(genres_tree, [], c14n_branches) + DataFileLoader.flatten_tree(genres_tree, [], c14n_branches) return c14n_branches, canonicalize + + @staticmethod + def flatten_tree( + elem: dict | list | str, + path: list[str], + branches: list[list[str]], + ) -> None: + """Flatten nested lists/dictionaries into lists of strings (branches).""" + if not path: + path = [] + + if isinstance(elem, dict): + for k, v in elem.items(): + DataFileLoader.flatten_tree(v, [*path, k], branches) + elif isinstance(elem, list): + for sub in elem: + DataFileLoader.flatten_tree(sub, path, branches) + else: + branches.append([*path, str(elem)]) From 65c00f3751eec9aa610dea21e58c51f5229a1b65 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sat, 7 Feb 2026 20:18:41 +0100 Subject: [PATCH 05/11] lastgenre: Tiny code style fix in _get_genre --- beetsplug/lastgenre/__init__.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 388962b651..6a61589f79 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -358,13 +358,12 @@ def _try_resolve_stage( if obj.genre and self.config["keep_existing"]: if not self.whitelist or self._is_valid(obj.genre.lower()): return obj.genre, "original fallback" - else: - # If the original genre doesn't match a whitelisted genre, check - # if we can canonicalize it to find a matching, whitelisted genre! - if result := _try_resolve_stage( - "original fallback", keep_genres, [] - ): - return result + # If the original genre doesn't match a whitelisted genre, check + # if we can canonicalize it to find a matching, whitelisted genre! + if result := _try_resolve_stage( + "original fallback", keep_genres, [] + ): + return result # Return fallback string. if fallback := self.config["fallback"].get(): From 0c21098ec095f65efe89919729197a7dfa7f35e8 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sat, 7 Feb 2026 20:37:10 +0100 Subject: [PATCH 06/11] lastgenre: Adapt existing tests to refactor --- test/plugins/test_lastgenre.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 3de43d197d..8d092f3189 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -184,9 +184,9 @@ def get_top_tags(self): return [tag1, tag2] plugin = lastgenre.LastGenrePlugin() - res = plugin._tags_for(MockPylastObj()) + res = plugin.client._tags_for(MockPylastObj()) assert res == ["pop", "rap"] - res = plugin._tags_for(MockPylastObj(), min_weight=50) + res = plugin.client._tags_for(MockPylastObj(), min_weight=50) assert res == ["pop"] def test_sort_by_depth(self): @@ -583,9 +583,9 @@ def mock_fetch_artist_genre(self, artist): # Mock the last.fm fetchers. When whitelist enabled, we can assume only # whitelisted genres get returned, the plugin's _resolve_genre method # ensures it. - lastgenre.LastGenrePlugin.fetch_track_genre = mock_fetch_track_genre - lastgenre.LastGenrePlugin.fetch_album_genre = mock_fetch_album_genre - lastgenre.LastGenrePlugin.fetch_artist_genre = mock_fetch_artist_genre + lastgenre.client.LastFmClient.fetch_track_genre = mock_fetch_track_genre + lastgenre.client.LastFmClient.fetch_album_genre = mock_fetch_album_genre + lastgenre.client.LastFmClient.fetch_artist_genre = mock_fetch_artist_genre # Initialize plugin instance and item plugin = lastgenre.LastGenrePlugin() From cfb30a2d272047e8b8b1ea3c3c85410f97e75d0c Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 8 Feb 2026 08:59:28 +0100 Subject: [PATCH 07/11] lastgenre: Fix all confuse get() usage --- beetsplug/lastgenre/__init__.py | 20 ++++++++++---------- beetsplug/lastgenre/loaders.py | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 6a61589f79..0d6cc56865 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -67,7 +67,7 @@ def __init__(self) -> None: def setup(self) -> None: """Setup plugin from config options""" - if self.config["auto"]: + if self.config["auto"].get(bool): self.import_stages = [self.imported] self._tunelog = make_tunelog(self._log) @@ -169,7 +169,7 @@ def _resolve_genres(self, tags: list[str]) -> list[str]: # Stop if we have enough tags already, unless we need to find # the most specific tag (instead of the most popular). if ( - not self.config["prefer_specific"] + not self.config["prefer_specific"].get(bool) and len(tags_all) >= count ): break @@ -178,7 +178,7 @@ def _resolve_genres(self, tags: list[str]) -> list[str]: tags = unique_list(tags) # Sort the tags by specificity. - if self.config["prefer_specific"]: + if self.config["prefer_specific"].get(bool): tags = self._sort_by_depth(tags) # c14n only adds allowed genres but we may have had forbidden genres in @@ -200,7 +200,7 @@ def _is_valid(self, genre: str) -> bool: def _format_and_stringify(self, tags: list[str]) -> str: """Format to title_case if configured and return as delimited string.""" - if self.config["title_case"]: + if self.config["title_case"].get(bool): formatted = [tag.title() for tag in tags] else: formatted = tags @@ -210,7 +210,7 @@ def _format_and_stringify(self, tags: list[str]) -> str: def _get_existing_genres(self, obj: LibModel) -> list[str]: """Return a list of genres for this Item or Album. Empty string genres are removed.""" - separator = self.config["separator"].get() + separator = self.config["separator"].as_str() if isinstance(obj, library.Item): item_genre = obj.get("genre", with_album=False).split(separator) else: @@ -267,14 +267,14 @@ def _try_resolve_stage( new_genres = [] genres = self._get_existing_genres(obj) - if genres and not self.config["force"]: + if genres and not self.config["force"].get(bool): # Without force pre-populated tags are returned as-is. label = "keep any, no-force" if isinstance(obj, library.Item): return obj.get("genre", with_album=False), label return obj.get("genre"), label - if self.config["force"]: + if self.config["force"].get(bool): # Force doesn't keep any unless keep_existing is set. # Whitelist validation is handled in _resolve_genres. if self.config["keep_existing"]: @@ -355,7 +355,7 @@ def _try_resolve_stage( return result # Nothing found, leave original if configured and valid. - if obj.genre and self.config["keep_existing"]: + if obj.genre and self.config["keep_existing"].get(bool): if not self.whitelist or self._is_valid(obj.genre.lower()): return obj.genre, "original fallback" # If the original genre doesn't match a whitelisted genre, check @@ -391,7 +391,7 @@ def _process(self, obj: LibModel, write: bool) -> None: def _process_track(self, obj: Item, write: bool) -> None: """Process a single track/item.""" self._fetch_and_log_genre(obj) - if not self.config["pretend"]: + if not self.config["pretend"].get(bool): obj.try_sync(write=write, move=False) @_process.register @@ -402,7 +402,7 @@ def _process_album(self, obj: Album, write: bool) -> None: for item in obj.items(): self._process(item, write) - if not self.config["pretend"]: + if not self.config["pretend"].get(bool): obj.try_sync( write=write, move=False, inherit="track" not in self.sources ) diff --git a/beetsplug/lastgenre/loaders.py b/beetsplug/lastgenre/loaders.py index 3d650bd5e3..c0132315e7 100644 --- a/beetsplug/lastgenre/loaders.py +++ b/beetsplug/lastgenre/loaders.py @@ -75,7 +75,7 @@ def from_config( log, config["canonical"].get(), default_tree, - config["prefer_specific"].get(), + config["prefer_specific"].get(bool), ) return cls(log, plugin_dir, whitelist, c14n_branches, canonicalize) From c077638b724d2014eb45de3f90148e9046985066 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 8 Feb 2026 09:12:27 +0100 Subject: [PATCH 08/11] lastgenre: Fix type hints after refactor --- beetsplug/lastgenre/loaders.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/beetsplug/lastgenre/loaders.py b/beetsplug/lastgenre/loaders.py index c0132315e7..0992ca14e2 100644 --- a/beetsplug/lastgenre/loaders.py +++ b/beetsplug/lastgenre/loaders.py @@ -19,7 +19,7 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import yaml @@ -92,7 +92,7 @@ def _load_whitelist( wl_filename = config_value if wl_filename in (True, "", None): # Indicates the default whitelist. wl_filename = default_path - if wl_filename: + if wl_filename and isinstance(wl_filename, str): log.debug("Loading whitelist {}", wl_filename) text = Path(wl_filename).expanduser().read_text(encoding="utf-8") for line in text.splitlines(): @@ -122,7 +122,7 @@ def _load_tree( ): c14n_filename = default_path # Read the tree - if c14n_filename: + if c14n_filename and isinstance(c14n_filename, str): log.debug("Loading canonicalization tree {}", c14n_filename) with Path(c14n_filename).expanduser().open(encoding="utf-8") as f: genres_tree = yaml.safe_load(f) @@ -131,7 +131,7 @@ def _load_tree( @staticmethod def flatten_tree( - elem: dict | list | str, + elem: dict[str, Any] | list[Any] | str, path: list[str], branches: list[list[str]], ) -> None: From 6207148aa35d7e53be37a8878f7caf2e5644b90e Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 8 Feb 2026 09:21:42 +0100 Subject: [PATCH 09/11] Changelog for #6353 lastgenre refactor --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 621dfbf2f4..3305639d2d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -31,6 +31,8 @@ Other changes - :doc:`plugins/lyrics`: Disable ``tekstowo`` by default because it blocks the beets User-Agent. +- :doc:`/plugins/lastgenre`: Refactored plugin into modular structure with + separate last.fm client, file loaders, and utils modules. 2.6.1 (February 02, 2026) ------------------------- From 9116de0c56fa7a4fefb4973ddb0b7ea9403b3bfe Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 8 Feb 2026 09:47:48 +0100 Subject: [PATCH 10/11] lastgenre: Simplify 'source' configuration handling borrowing the idea from "fully typed confuse PR" #6268 --- beetsplug/lastgenre/__init__.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 0d6cc56865..47f0f66ca2 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -87,14 +87,13 @@ def sources(self) -> tuple[str, ...]: """A tuple of allowed genre sources. May contain 'track', 'album', or 'artist.' """ - source = self.config["source"].as_choice(("track", "album", "artist")) - if source == "track": - return "track", "album", "artist" - if source == "album": - return "album", "artist" - if source == "artist": - return ("artist",) - return tuple() + return self.config["source"].as_choice( + { + "track": ("track", "album", "artist"), + "album": ("album", "artist"), + "artist": ("artist",), + } + ) # Canonicalization and filtering. From 011012c3140e39ad898a13adb2b03a9774a22cb2 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 15 Feb 2026 11:38:40 +0100 Subject: [PATCH 11/11] lastgenre: Named types; Document flatten_tree - Define types for whitelist and canonicalization tree - Better document the flatten_tree function --- beetsplug/lastgenre/__init__.py | 4 +++- beetsplug/lastgenre/loaders.py | 27 ++++++++++++++++++++++----- beetsplug/lastgenre/types.py | 25 +++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 beetsplug/lastgenre/types.py diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 47f0f66ca2..6f657fb480 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -41,6 +41,8 @@ from beets.library import LibModel + from .types import CanonTree + class LastGenrePlugin(plugins.BeetsPlugin): def __init__(self) -> None: @@ -116,7 +118,7 @@ def _sort_by_depth(self, tags: list[str]) -> list[str]: return [p[1] for p in depth_tag_pairs] @staticmethod - def find_parents(candidate: str, branches: list[list[str]]) -> list[str]: + def find_parents(candidate: str, branches: CanonTree) -> list[str]: """Find parent genres of a given genre, ordered from closest to furthest.""" for branch in branches: try: diff --git a/beetsplug/lastgenre/loaders.py b/beetsplug/lastgenre/loaders.py index 0992ca14e2..dc484f7e14 100644 --- a/beetsplug/lastgenre/loaders.py +++ b/beetsplug/lastgenre/loaders.py @@ -28,6 +28,8 @@ from beets.logging import Logger + from .types import CanonTree, Whitelist + class DataFileLoader: """Loads genre-related data files for the lastgenre plugin.""" @@ -83,7 +85,7 @@ def from_config( @staticmethod def _load_whitelist( log: Logger, config_value: str | bool | None, default_path: str - ) -> set[str]: + ) -> Whitelist: """Load the whitelist from a text file. Returns set of valid genre names (lowercase). @@ -107,12 +109,12 @@ def _load_tree( config_value: str | bool | None, default_path: str, prefer_specific: bool, - ) -> tuple[list[list[str]], bool]: + ) -> tuple[CanonTree, bool]: """Load the canonicalization tree from a YAML file. Returns tuple of (branches, canonicalize_enabled). """ - c14n_branches: list[list[str]] = [] + c14n_branches: CanonTree = [] c14n_filename = config_value canonicalize = c14n_filename is not False # Default tree @@ -133,9 +135,24 @@ def _load_tree( def flatten_tree( elem: dict[str, Any] | list[Any] | str, path: list[str], - branches: list[list[str]], + branches: CanonTree, ) -> None: - """Flatten nested lists/dictionaries into lists of strings (branches).""" + """Flatten nested YAML structure into genre hierarchy branches. + + Recursively converts nested dicts/lists from YAML into a flat list + of genre paths, where each path goes from general to specific genre. + + Args: + elem: The YAML element to process (dict, list, or string leaf). + path: Current path from root to this element (used in recursion). + branches: OUTPUT PARAMETER - Empty list that will be populated + with genre paths. Gets mutated by this method. + + Example: + branches = [] + flatten_tree({'rock': ['indie', 'punk']}, [], branches) + # branches is now: [['rock', 'indie'], ['rock', 'punk']] + """ if not path: path = [] diff --git a/beetsplug/lastgenre/types.py b/beetsplug/lastgenre/types.py new file mode 100644 index 0000000000..736811726f --- /dev/null +++ b/beetsplug/lastgenre/types.py @@ -0,0 +1,25 @@ +# This file is part of beets. +# Copyright 2026, J0J0 Todos. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + + +"""Type aliases for the lastgenre plugin.""" + +from __future__ import annotations + +Whitelist = set[str] +"""Set of valid genre names (lowercase). Empty set means all genres allowed.""" + +CanonTree = list[list[str]] +"""Genre hierarchy as list of paths from general to specific. +Example: [['electronic', 'house'], ['electronic', 'techno']]"""