lastgenre: Use albumartists field to improve last.fm results (#5981)

Often last.fm does not find artist genres for delimiter-separated artist
names (eg. "Artist One, Artist Two") or where multiple artists are
combined with "concatenation words" like "and" , "+", "featuring" and so
on.

This fix gathers each artist's last.fm genre separately by using Beets'
mutli-valued `albumartists` field to improve the likeliness of finding
genres in the artist genre fetching stage.

Refactoring was done along the existing genre fetching helper functions
(`fetch_album_genre`, `fetch_track_genre`, ...):

- last.fm can be asked for genre for these combinations of metadata:
   - albumartist/album
   - artist/track
   - artist
- Instead of passing `Album` or `Item` objects directly to these
helpers., generalize them and pass the (string) metadata directly.
- Passing "what's to fetch" in the callers instead of hiding it in the
methods also helps readability in `_get_genre()`
- And reduces the requirement at hand for another additional method (or
adaptation) to support "multi-albumartist genre fetching"
This commit is contained in:
J0J0 Todos 2025-12-30 21:03:38 +01:00 committed by GitHub
commit 8dd6988077
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 51 additions and 30 deletions

View file

@ -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,31 +297,27 @@ 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, obj):
"""Return raw album genres from Last.fm for this Item or Album."""
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, 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: str) -> list[str]:
"""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: str, tracktitle: str) -> list[str]:
"""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 +405,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,20 +421,36 @@ 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"
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 = []
assert isinstance(obj, Album) # Type narrowing for mypy
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:

View file

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

View file

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