Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 31 additions & 19 deletions beetsplug/lastgenre/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,24 +300,20 @@ def _last_lookup(self, entity, method, *args):
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.
Expand Down Expand Up @@ -405,14 +401,14 @@ def _try_resolve_stage(stage_label: str, keep_genres, new_genres):
# 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
):
Expand All @@ -421,20 +417,36 @@ def _try_resolve_stage(stage_label: str, keep_genres, new_genres):
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Use runtime type checking instead of assert for production code.

Consider replacing the assert with an explicit isinstance check and appropriate error handling, since asserts can be disabled in production environments.

Suggested change
assert isinstance(obj, Album) # Type narrowing for mypy
if not isinstance(obj, Album):
raise TypeError(f"Expected obj to be an Album, got {type(obj).__name__}")

Copy link
Member Author

@JOJ0 JOJ0 Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was quite tricky to fix. The original mypy error was:

$ mypy beetsplug/lastgenre/__init__.py --show-error-codes
beetsplug/lastgenre/__init__.py:443: error: "tuple[str, Any]" has no attribute "artist"  [attr-defined]
beetsplug/lastgenre/__init__.py:443: error: "tuple[str, Any]" has no attribute "title"  [attr-defined]
beetsplug/lastgenre/__init__.py:446: error: "tuple[str, Any]" has no attribute "artist"  [attr-defined]
Found 3 errors in 1 file (checked 1 source file)

With the assert I'm convincing mypy to consider obj being an Album object, otherwise it would have detected it as an [Iterable[tuple[str, Any]]] which LibModel.items() returns.

This seemed to be the most easy fix. I tried to fiddle around with changing types within models.py:

class LibModel(dbcore.Model["Library"]):

but I'm out of my depth here. Some advice for a better fix or if that's a good enough one already please @semohr ?

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:
Expand Down
5 changes: 5 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ Bug fixes:
endpoints. Previously, due to single-quotes (ie. string literal) in the SQL
query, the query eg. `GET /item/values/albumartist` would return the literal
"albumartist" instead of a list of unique album artists.
- :doc:`plugins/lastgenre`: Fix the issue where last.fm does not give a result in
the artist genre stage because multi-artist "concatenation" words (like
"feat." "+", or "&" prevent exact matches. Using the albumartists list field
and fetching a genre for each artist separately massively improves the chance
to get a valid result in that stage.

For plugin developers:

Expand Down
6 changes: 3 additions & 3 deletions test/plugins/test_lastgenre.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,13 +546,13 @@ def test_sort_by_depth(self):
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
Expand Down
Loading