Merge branch 'master' into plugin-loading-debug

This commit is contained in:
Šarūnas Nejus 2025-08-24 13:13:24 +01:00 committed by GitHub
commit f6a19c7b83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 139 additions and 64 deletions

View file

@ -42,10 +42,6 @@ PYLAST_EXCEPTIONS = (
pylast.NetworkError,
)
REPLACE = {
"\u2010": "-",
}
# Canonicalization tree processing.
@ -184,31 +180,28 @@ class LastGenrePlugin(plugins.BeetsPlugin):
return [p[1] for p in depth_tag_pairs]
def _resolve_genres(self, tags: list[str]) -> list[str]:
"""Filter, deduplicate, sort, canonicalize provided genres list.
"""Canonicalize, sort and filter a list of genres.
- Returns an empty list if the input tags list is empty.
- If canonicalization is enabled, it extends the list by incorporating
parent genres from the canonicalization tree. When a whitelist is set,
only parent tags that pass a validity check (_is_valid) are included;
otherwise, it adds the oldest ancestor.
- During canonicalization, it stops adding parent tags if the count of
tags reaches the configured limit (count).
otherwise, it adds the oldest ancestor. Adding parent tags is stopped
when the count of tags reaches the configured limit (count).
- The tags list is then deduplicated to ensure only unique genres are
retained.
- Optionally, if the 'prefer_specific' configuration is enabled, the
list is sorted by the specificity (depth in the canonicalization tree)
of the genres.
- The method then filters the tag list, ensuring that only valid
genres (those that pass the _is_valid method) are kept. If a
whitelist is set, only genres in the whitelist are considered valid
(which may even result in no genres at all being retained).
- Finally, the filtered list of genres, limited to
the configured count is returned.
- If the 'prefer_specific' configuration is enabled, the list is sorted
by the specificity (depth in the canonicalization tree) of the genres.
- Finally applies whitelist filtering to ensure that only valid
genres are kept. (This may result in no genres at all being retained).
- Returns the filtered list of genres, limited to the configured count.
"""
if not tags:
return []
count = self.config["count"].get(int)
# Canonicalization (if enabled)
if self.canonicalize:
# Extend the list to consider tags parents in the c14n tree
tags_all = []
@ -242,8 +235,8 @@ class LastGenrePlugin(plugins.BeetsPlugin):
# c14n only adds allowed genres but we may have had forbidden genres in
# the original tags list
valid_tags = self._filter_valid_genres(tags)
return valid_tags[: self.config["count"].get(int)]
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
@ -252,12 +245,6 @@ class LastGenrePlugin(plugins.BeetsPlugin):
min_weight = self.config["min_weight"].get(int)
return self._tags_for(lastfm_obj, min_weight)
def _filter_valid_genres(self, genres: list[str]) -> list[str]:
"""Filter list of genres, only keep valid."""
if not genres:
return []
return [x for x in genres if self._is_valid(x)]
def _is_valid(self, genre: str) -> bool:
"""Check if the genre is valid.
@ -281,7 +268,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
"""
# Shortcut if we're missing metadata.
if any(not s for s in args):
return None
return []
key = f"{entity}.{'-'.join(str(a) for a in args)}"
if key not in self._genre_cache:
@ -294,29 +281,23 @@ class LastGenrePlugin(plugins.BeetsPlugin):
return genre
def fetch_album_genre(self, obj):
"""Return the album genre for this Item or Album."""
return self._filter_valid_genres(
self._last_lookup(
"album", LASTFM.get_album, obj.albumartist, obj.album
)
"""Return raw album genres from Last.fm for this Item or Album."""
return self._last_lookup(
"album", LASTFM.get_album, obj.albumartist, obj.album
)
def fetch_album_artist_genre(self, obj):
"""Return the album artist genre for this Item or Album."""
return self._filter_valid_genres(
self._last_lookup("artist", LASTFM.get_artist, obj.albumartist)
)
"""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, item):
"""Returns the track artist genre for this Item."""
return self._filter_valid_genres(
self._last_lookup("artist", LASTFM.get_artist, item.artist)
)
"""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 the track genre for this Item."""
return self._filter_valid_genres(
self._last_lookup("track", LASTFM.get_track, obj.artist, obj.title)
"""Returns raw track genres from Last.fm for this Item."""
return self._last_lookup(
"track", LASTFM.get_track, obj.artist, obj.title
)
# Main processing: _get_genre() and helpers.
@ -346,7 +327,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
self, old: list[str], new: list[str]
) -> list[str]:
"""Combine old and new genres and process via _resolve_genres."""
self._log.debug(f"valid last.fm tags: {new}")
self._log.debug(f"raw last.fm tags: {new}")
self._log.debug(f"existing genres taken into account: {old}")
combined = old + new
return self._resolve_genres(combined)
@ -372,9 +353,22 @@ class LastGenrePlugin(plugins.BeetsPlugin):
applied, while "artist, any" means only new last.fm genres are included
and the whitelist feature was disabled.
"""
def _try_resolve_stage(stage_label: str, keep_genres, new_genres):
"""Try to resolve genres for a given stage and log the result."""
resolved_genres = self._combine_resolve_and_log(
keep_genres, new_genres
)
if resolved_genres:
suffix = "whitelist" if self.whitelist else "any"
label = stage_label + f", {suffix}"
if keep_genres:
label = f"keep + {label}"
return self._format_and_stringify(resolved_genres), label
return None
keep_genres = []
new_genres = []
label = ""
genres = self._get_existing_genres(obj)
if genres and not self.config["force"]:
@ -394,20 +388,26 @@ class LastGenrePlugin(plugins.BeetsPlugin):
# 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):
label = "track"
if result := _try_resolve_stage(
"track", keep_genres, new_genres
):
return result
if not new_genres and "album" in self.sources:
if "album" in self.sources:
if new_genres := self.fetch_album_genre(obj):
label = "album"
if result := _try_resolve_stage(
"album", keep_genres, new_genres
):
return result
if not new_genres and "artist" in self.sources:
if "artist" in self.sources:
new_genres = []
if isinstance(obj, library.Item):
new_genres = self.fetch_artist_genre(obj)
label = "artist"
stage_label = "artist"
elif obj.albumartist != config["va_name"].as_str():
new_genres = self.fetch_album_artist_genre(obj)
label = "album artist"
stage_label = "album artist"
else:
# For "Various Artists", pick the most popular track genre.
item_genres = []
@ -422,24 +422,18 @@ class LastGenrePlugin(plugins.BeetsPlugin):
if item_genres:
most_popular, rank = plurality(item_genres)
new_genres = [most_popular]
label = "most popular track"
stage_label = "most popular track"
self._log.debug(
'Most popular track genre "{}" ({}) for VA album.',
most_popular,
rank,
)
# Return with a combined or freshly fetched genre list.
if new_genres:
resolved_genres = self._combine_resolve_and_log(
keep_genres, new_genres
)
if resolved_genres:
suffix = "whitelist" if self.whitelist else "any"
label += f", {suffix}"
if keep_genres:
label = f"keep + {label}"
return self._format_and_stringify(resolved_genres), label
if new_genres:
if result := _try_resolve_stage(
stage_label, keep_genres, new_genres
):
return result
# Nothing found, leave original if configured and valid.
if obj.genre and self.config["keep_existing"]:

View file

@ -52,6 +52,9 @@ Bug fixes:
the config option ``spotify.search_query_ascii: yes``. :bug:`5699`
- :doc:`plugins/discogs`: Beets will no longer crash if a release has been
deleted, and returns a 404.
- :doc:`plugins/lastgenre`: Fix the issue introduced in Beets 2.3.0 where
non-whitelisted last.fm genres were not canonicalized to parent genres.
:bug:`5930`
For packagers:
@ -117,6 +120,7 @@ Other changes:
- Refactored library.py file by splitting it into multiple modules within the
beets/library directory.
- Added a test to check that all plugins can be imported without errors.
- :doc:`/guides/main`: Add instructions to install beets on Void Linux.
2.3.1 (May 14, 2025)
--------------------

View file

@ -26,6 +26,8 @@ You will need Python. Beets works on Python 3.8 or later.
which will probably set your computer on fire.)
- On **Alpine Linux**, `beets is in the community repository <alpine package_>`_
and can be installed with ``apk add beets``.
- On **Void Linux**, `beets is in the official repository <void package_>`_ and
can be installed with ``xbps-install -S beets``.
- For **Gentoo Linux**, beets is in Portage as ``media-sound/beets``. Just run
``emerge beets`` to install. There are several USE flags available for
optional plugin dependencies.
@ -61,6 +63,8 @@ You will need Python. Beets works on Python 3.8 or later.
.. _ubuntu details: https://launchpad.net/ubuntu/+source/beets
.. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets
If you have pip_, just say ``pip install beets`` (or ``pip install --user
beets`` if you run into permissions problems).

View file

@ -147,8 +147,9 @@ Add new last.fm genres when **empty**. Any present tags stay **untouched**.
**Setup 3**
**Combine** genres in present tags with new ones (be aware of that with an
enabled ``whitelist`` setting, of course some genres might get cleaned up. To
make sure any existing genres remain, set ``whitelist: no``).
enabled ``whitelist`` setting, of course some genres might get cleaned up -
existing genres take precedence over new ones though. To make sure any existing
genres remain, set ``whitelist: no``).
.. code-block:: yaml

View file

@ -441,6 +441,77 @@ class LastGenrePluginTest(BeetsTestCase):
},
("Jazz", "keep + artist, whitelist"),
),
# 13 - canonicalization transforms non-whitelisted genres to canonical forms
#
# "Acid Techno" is not in the default whitelist, thus gets resolved "up" in the
# tree to "Techno" and "Electronic".
(
{
"force": True,
"keep_existing": False,
"source": "album",
"whitelist": True,
"canonical": True,
"prefer_specific": False,
"count": 10,
},
"",
{
"album": ["acid techno"],
},
("Techno, Electronic", "album, whitelist"),
),
# 14 - canonicalization transforms whitelisted genres to canonical forms and
# includes originals
#
# "Detroit Techno" is in the default whitelist, thus it stays and and also gets
# resolved "up" in the tree to "Techno" and "Electronic". The same happens for
# newly fetched genre "Acid House".
(
{
"force": True,
"keep_existing": True,
"source": "album",
"whitelist": True,
"canonical": True,
"prefer_specific": False,
"count": 10,
"extended_debug": True,
},
"detroit techno",
{
"album": ["acid house"],
},
(
"Detroit Techno, Techno, Electronic, Acid House, House",
"keep + album, whitelist",
),
),
# 15 - canonicalization transforms non-whitelisted original genres to canonical
# forms and deduplication works.
#
# "Cosmic Disco" is not in the default whitelist, thus gets resolved "up" in the
# tree to "Disco" and "Electronic". New genre "Detroit Techno" resolves to
# "Techno". Both resolve to "Electronic" which gets deduplicated.
(
{
"force": True,
"keep_existing": True,
"source": "album",
"whitelist": True,
"canonical": True,
"prefer_specific": False,
"count": 10,
},
"Cosmic Disco",
{
"album": ["Detroit Techno"],
},
(
"Disco, Electronic, Detroit Techno, Techno",
"keep + album, whitelist",
),
),
],
)
def test_get_genre(config_values, item_genre, mock_genres, expected_result):
@ -466,6 +537,7 @@ def test_get_genre(config_values, item_genre, mock_genres, expected_result):
plugin = lastgenre.LastGenrePlugin()
# Configure
plugin.config.set(config_values)
plugin.setup() # Loads default whitelist and canonicalization tree
item = _common.item()
item.genre = item_genre