Skip to content
Merged
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
70 changes: 43 additions & 27 deletions beetsplug/lastgenre/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
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
Expand Down Expand Up @@ -259,9 +259,11 @@ 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):
"""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)
Expand All @@ -278,8 +280,10 @@ def _is_valid(self, genre: str) -> bool:

# 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.

Expand All @@ -293,31 +297,27 @@ def _last_lookup(self, entity, method, *args):

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.
Expand Down Expand Up @@ -405,14 +405,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 +421,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
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 @@ -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:

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