From 40a212a2c4d660d7f527d30a41bd419b0160e826 Mon Sep 17 00:00:00 2001 From: j0j0 Date: Sun, 16 Nov 2025 08:29:51 +0100 Subject: [PATCH 1/7] lastgenre: Simplify genre fetchers Reduce fetcher methods to 3: last.fm can be asked for for a genre for these combinations of metadata: - albumartist/album - artist/track - artist Passing them in the callers instead of hiding it in the methods also helps readability in _get_genre(). --- beetsplug/lastgenre/__init__.py | 36 ++++++++++++++++----------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index ea0ab951a..698365078 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -300,24 +300,20 @@ class LastGenrePlugin(plugins.BeetsPlugin): self._tunelog("last.fm (unfiltered) {} tags: {}", entity, genre) return genre - def fetch_album_genre(self, obj): - """Return raw album genres from Last.fm for this Item or Album.""" + def fetch_album_genre(self, albumartist, albumtitle): + """Return genres from Last.fm for the album by albumartist.""" return self._last_lookup( - "album", LASTFM.get_album, obj.albumartist, obj.album + "album", LASTFM.get_album, albumartist, albumtitle ) - def fetch_album_artist_genre(self, obj): - """Return raw album artist genres from Last.fm for this Item or Album.""" - return self._last_lookup("artist", LASTFM.get_artist, obj.albumartist) + def fetch_artist_genre(self, artist): + """Return genres from Last.fm for the artist.""" + return self._last_lookup("artist", LASTFM.get_artist, artist) - def fetch_artist_genre(self, item): - """Returns raw track artist genres from Last.fm for this Item.""" - return self._last_lookup("artist", LASTFM.get_artist, item.artist) - - def fetch_track_genre(self, obj): - """Returns raw track genres from Last.fm for this Item.""" + def fetch_track_genre(self, trackartist, tracktitle): + """Return genres from Last.fm for the track by artist.""" return self._last_lookup( - "track", LASTFM.get_track, obj.artist, obj.title + "track", LASTFM.get_track, trackartist, tracktitle ) # Main processing: _get_genre() and helpers. @@ -405,14 +401,14 @@ class LastGenrePlugin(plugins.BeetsPlugin): # 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): + if new_genres := self.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): + if new_genres := self.fetch_album_genre(obj.albumartist, obj.album): if result := _try_resolve_stage( "album", keep_genres, new_genres ): @@ -421,10 +417,10 @@ class LastGenrePlugin(plugins.BeetsPlugin): if "artist" in self.sources: new_genres = [] if isinstance(obj, library.Item): - new_genres = self.fetch_artist_genre(obj) + new_genres = self.fetch_artist_genre(obj.artist) stage_label = "artist" elif obj.albumartist != config["va_name"].as_str(): - new_genres = self.fetch_album_artist_genre(obj) + new_genres = self.fetch_artist_genre(obj.albumartist) stage_label = "album artist" else: # For "Various Artists", pick the most popular track genre. @@ -432,9 +428,11 @@ class LastGenrePlugin(plugins.BeetsPlugin): for item in obj.items(): item_genre = None if "track" in self.sources: - item_genre = self.fetch_track_genre(item) + item_genre = self.fetch_track_genre( + item.artist, item.title + ) if not item_genre: - item_genre = self.fetch_artist_genre(item) + item_genre = self.fetch_artist_genre(item.artist) if item_genre: item_genres += item_genre if item_genres: From 355c9cc1b608b1e5ed5956eac0b7e32a4b9bff62 Mon Sep 17 00:00:00 2001 From: j0j0 Date: Sun, 16 Nov 2025 08:40:15 +0100 Subject: [PATCH 2/7] lastgenre: Use multi-valued albumartists field In case the albumartist genre can't be found (often due to variations of artist-combination wording issues, eg "featuring", "+", "&" and so on) use the albumartists list field, fetch a genre for each artist separately and concatenate them. --- beetsplug/lastgenre/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 698365078..ba85c3871 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -422,6 +422,19 @@ class LastGenrePlugin(plugins.BeetsPlugin): elif obj.albumartist != config["va_name"].as_str(): new_genres = self.fetch_artist_genre(obj.albumartist) stage_label = "album artist" + if not new_genres: + self._tunelog( + 'No album artist genre found for "{}", ' + "trying multi-valued field...", + obj.albumartist, + ) + for albumartist in obj.albumartists: + self._tunelog( + 'Fetching artist genre for "{}"', albumartist + ) + new_genres += self.fetch_artist_genre(albumartist) + if new_genres: + stage_label = "multi-valued album artist" else: # For "Various Artists", pick the most popular track genre. item_genres = [] From a046f60c5173bd9c15c1402cab00383f01283cb0 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Wed, 19 Nov 2025 07:16:26 +0100 Subject: [PATCH 3/7] lastgenre: Hint mypy to Album.items() instead of obj.items() --- beetsplug/lastgenre/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index ba85c3871..40019f548 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -438,6 +438,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): else: # For "Various Artists", pick the most popular track genre. item_genres = [] + assert isinstance(obj, Album) # Type narrowing for mypy for item in obj.items(): item_genre = None if "track" in self.sources: From d72307a16ff3c08b2949fe99d433c257e7148641 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Thu, 20 Nov 2025 06:01:51 +0100 Subject: [PATCH 4/7] lastgenre: Adapt test_get_genre function signatures --- test/plugins/test_lastgenre.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 12ff30f8e..026001e38 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -546,13 +546,13 @@ class LastGenrePluginTest(PluginTestCase): def test_get_genre(config_values, item_genre, mock_genres, expected_result): """Test _get_genre with various configurations.""" - def mock_fetch_track_genre(self, obj=None): + def mock_fetch_track_genre(self, trackartist, tracktitle): return mock_genres["track"] - def mock_fetch_album_genre(self, obj): + def mock_fetch_album_genre(self, albumartist, albumtitle): return mock_genres["album"] - def mock_fetch_artist_genre(self, obj): + def mock_fetch_artist_genre(self, artist): return mock_genres["artist"] # Mock the last.fm fetchers. When whitelist enabled, we can assume only From f19d672016dbfae8b9296f4a07d2c98db7771ce3 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Thu, 25 Dec 2025 10:36:20 +0100 Subject: [PATCH 5/7] lastgenre: Type hints for genre fetch methods --- beetsplug/lastgenre/__init__.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 40019f548..3d4d5b6b0 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -28,7 +28,7 @@ import os import traceback from functools import singledispatchmethod from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Callable import pylast import yaml @@ -259,9 +259,11 @@ class LastGenrePlugin(plugins.BeetsPlugin): valid_tags = [t for t in tags if self._is_valid(t)] return valid_tags[:count] - def fetch_genre(self, lastfm_obj): - """Return the genre for a pylast entity or None if no suitable genre - can be found. Ex. 'Electronic, House, Dance' + 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) @@ -278,8 +280,10 @@ class LastGenrePlugin(plugins.BeetsPlugin): # Cached last.fm entity lookups. - def _last_lookup(self, entity, method, *args): - """Get a genre based on the named entity using the callable `method` + 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. @@ -293,24 +297,24 @@ class LastGenrePlugin(plugins.BeetsPlugin): key = f"{entity}.{'-'.join(str(a) for a in args)}" if key not in self._genre_cache: - args = [a.replace("\u2010", "-") for a in args] - self._genre_cache[key] = self.fetch_genre(method(*args)) + 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, albumtitle): + 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): + 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, tracktitle): + 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 From 28dc78be95c3f862bc578a8e2a2dc689264f5bad Mon Sep 17 00:00:00 2001 From: j0j0 Date: Sun, 16 Nov 2025 08:54:12 +0100 Subject: [PATCH 6/7] lastgenre: Changelog for #5981 lastgenre --- docs/changelog.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0cfc1af24..49402bad7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -73,6 +73,11 @@ Bug fixes: cancelling an edit session during import. :bug:`6104` - :ref:`update-cmd` :doc:`plugins/edit` fix display formatting of field changes to clearly show added and removed flexible fields. +- :doc:`plugins/lastgenre`: Fix the issue where last.fm doesn't return any + result in the artist genre stage because "concatenation" words in the artist + name (like "feat.", "+", or "&") prevent it. Using the albumartists list field + and fetching a genre for each artist separately improves the chance of + receiving valid results in that stage. For plugin developers: From b8c7c87b41f995dddd0aa3206f8196d946e81747 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Thu, 25 Dec 2025 10:50:02 +0100 Subject: [PATCH 7/7] lastgenre: Add typehints to remaining methods, to finally reach full type hint coverage in the plugin! --- beetsplug/lastgenre/__init__.py | 51 ++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 3d4d5b6b0..e622096cf 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -38,6 +38,8 @@ from beets.library import Album, Item from beets.util import plurality, unique_list if TYPE_CHECKING: + import optparse + from beets.library import LibModel LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY) @@ -52,7 +54,11 @@ PYLAST_EXCEPTIONS = ( # Canonicalization tree processing. -def flatten_tree(elem, path, branches): +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). """ @@ -69,7 +75,7 @@ def flatten_tree(elem, path, branches): branches.append(path + [str(elem)]) -def find_parents(candidate, branches): +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. """ @@ -89,7 +95,7 @@ C14N_TREE = os.path.join(os.path.dirname(__file__), "genres-tree.yaml") class LastGenrePlugin(plugins.BeetsPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__() self.config.add( @@ -111,12 +117,12 @@ class LastGenrePlugin(plugins.BeetsPlugin): ) self.setup() - def setup(self): + def setup(self) -> None: """Setup plugin from config options""" if self.config["auto"]: self.import_stages = [self.imported] - self._genre_cache = {} + self._genre_cache: dict[str, list[str]] = {} self.whitelist = self._load_whitelist() self.c14n_branches, self.canonicalize = self._load_c14n_tree() @@ -161,7 +167,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): flatten_tree(genres_tree, [], c14n_branches) return c14n_branches, canonicalize - def _tunelog(self, msg, *args, **kwargs): + 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) @@ -182,7 +188,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): # More canonicalization and general helpers. - def _get_depth(self, tag): + def _get_depth(self, tag: str) -> int | None: """Find the depth of a tag in the genres tree.""" depth = None for key, value in enumerate(self.c14n_branches): @@ -191,7 +197,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): break return depth - def _sort_by_depth(self, tags): + def _sort_by_depth(self, tags: list[str]) -> list[str]: """Given a list of tags, sort the tags by their depths in the genre tree. """ @@ -372,7 +378,9 @@ class LastGenrePlugin(plugins.BeetsPlugin): and the whitelist feature was disabled. """ - def _try_resolve_stage(stage_label: str, keep_genres, new_genres): + def _try_resolve_stage( + stage_label: str, keep_genres: list[str], new_genres: list[str] + ) -> tuple[str, str] | None: """Try to resolve genres for a given stage and log the result.""" resolved_genres = self._combine_resolve_and_log( keep_genres, new_genres @@ -516,7 +524,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): write=write, move=False, inherit="track" not in self.sources ) - def commands(self): + def commands(self) -> list[ui.Subcommand]: lastgenre_cmd = ui.Subcommand("lastgenre", help="fetch genres") lastgenre_cmd.parser.add_option( "-p", @@ -575,7 +583,9 @@ class LastGenrePlugin(plugins.BeetsPlugin): ) lastgenre_cmd.parser.set_defaults(album=True) - def lastgenre_func(lib, opts, args): + def lastgenre_func( + lib: library.Library, opts: optparse.Values, args: list[str] + ) -> None: self.config.set_args(opts) method = lib.albums if opts.album else lib.items @@ -585,10 +595,16 @@ class LastGenrePlugin(plugins.BeetsPlugin): lastgenre_cmd.func = lastgenre_func return [lastgenre_cmd] - def imported(self, session, task): + 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, min_weight=None): + 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 @@ -600,11 +616,12 @@ class LastGenrePlugin(plugins.BeetsPlugin): # 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 = super(pylast.Album, obj) + obj_to_query = super(pylast.Album, obj) try: - res = obj.get_top_tags() + res: Any = obj_to_query.get_top_tags() except PYLAST_EXCEPTIONS as exc: self._log.debug("last.fm error: {}", exc) return [] @@ -619,6 +636,6 @@ class LastGenrePlugin(plugins.BeetsPlugin): res = [el for el in res if (int(el.weight or 0)) >= min_weight] # Get strings from tags. - res = [el.item.get_name().lower() for el in res] + tags: list[str] = [el.item.get_name().lower() for el in res] - return res + return tags