mirror of
https://github.com/beetbox/beets.git
synced 2026-01-10 18:07:00 +01:00
Merge branch 'master' into plugin-loading-debug
This commit is contained in:
commit
f6a19c7b83
5 changed files with 139 additions and 64 deletions
|
|
@ -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"]:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
--------------------
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue