From 40a212a2c4d660d7f527d30a41bd419b0160e826 Mon Sep 17 00:00:00 2001 From: j0j0 Date: Sun, 16 Nov 2025 08:29:51 +0100 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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/6] 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/6] 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: