From 6dfde732b09bc054b7ed3b19d96bdcdb0e42ba56 Mon Sep 17 00:00:00 2001 From: Hendrik Boll Date: Fri, 8 Aug 2025 12:37:38 +0200 Subject: [PATCH 01/10] readme: add void linux --- docs/changelog.rst | 1 + docs/guides/main.rst | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index ab896a7ff..2c5147017 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -117,6 +117,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) -------------------- diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 3e9c880ff..93f3d62cf 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -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 `_ and can be installed with ``apk add beets``. +- On **Void Linux**, `beets is in the official repository `_ + 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. @@ -53,6 +55,8 @@ You will need Python. Beets works on Python 3.8 or later. .. _macports: https://www.macports.org +.. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets + .. _nixos: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets .. _openbsd: http://openports.se/audio/beets From 5c7d49e24e4a6e4b6f89c68df2dce3c574cbfb0f Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Fri, 22 Aug 2025 11:30:47 +0200 Subject: [PATCH 02/10] Quick docfmt. --- docs/guides/main.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 93f3d62cf..bbb0ea858 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -26,8 +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 `_ and can be installed with ``apk add beets``. -- On **Void Linux**, `beets is in the official repository `_ - and can be installed with ``xbps-install -S beets``. +- On **Void Linux**, `beets is in the official repository `_ 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. @@ -55,8 +55,6 @@ You will need Python. Beets works on Python 3.8 or later. .. _macports: https://www.macports.org -.. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets - .. _nixos: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets .. _openbsd: http://openports.se/audio/beets @@ -65,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). From 7f7b900f1b25690cf5a2e616c7235cdac2fb4aac Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Tue, 19 Aug 2025 07:02:58 +0200 Subject: [PATCH 03/10] lastgenre: Test canonicalization - Test non-whitelisted genres resolving "up" in the tree. - Test whitelisted original and whitelisted new genre resolving "up" - Test non-whitelisted original genre resolving "up" (and deduplication works) --- test/plugins/test_lastgenre.py | 71 ++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index be145d811..81bfdd5ab 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -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): From fa8b5d7495add9bf264462c85846e9c73a53890d Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Mon, 18 Aug 2025 09:32:46 +0200 Subject: [PATCH 04/10] lastgenre: Fix canonicalization of non-valid genres - Remove "early whitelist check", since it breaks canonicalization of actually unwanted genres (not whitelisted) resolving "up" to parent genres. - Remove the filter_valid_genres method entirely and get back to inline list comprehensions. The caveat is that None genres are not catched that way (see below, should be one of the last functions that finally returns lists only) - Along the way, fix _last_lookup's rearly return to empty list instead of None. --- beetsplug/lastgenre/__init__.py | 63 +++++++++++++-------------------- 1 file changed, 24 insertions(+), 39 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index dbab96cf8..e3b5012ca 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -184,31 +184,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 +239,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 +249,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 +272,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 +285,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 +331,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) From f85ba7ab3b7769e437b2bce4e6a667a0ef969264 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Tue, 19 Aug 2025 07:56:36 +0200 Subject: [PATCH 05/10] lastgenre: Fix test_get_genre loading whitelist - The default whitelist files were not loaded properly (at least in local test environments, not sure about CI yet...anyway...) --- test/plugins/test_lastgenre.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 81bfdd5ab..72b0d4f00 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -537,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 From d8e90d8e5493aa44062be7db155f36622bc6ed5a Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Wed, 20 Aug 2025 07:35:47 +0200 Subject: [PATCH 06/10] lastgenre: Resolve combined genres in each stage To ensure proper fallback to the next stage, in each stage we do a full combine/resolve/log. Also we directly return if have satisfied results. As a bonus this improves readability. Some duplicate code on the label magic though... --- beetsplug/lastgenre/__init__.py | 46 +++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index e3b5012ca..44c443a18 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -380,12 +380,32 @@ class LastGenrePlugin(plugins.BeetsPlugin): if isinstance(obj, library.Item) and "track" in self.sources: if new_genres := self.fetch_track_genre(obj): label = "track" + 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 + new_genres = [] - if not new_genres and "album" in self.sources: + if "album" in self.sources: if new_genres := self.fetch_album_genre(obj): label = "album" + 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 + new_genres = [] - 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) @@ -414,17 +434,17 @@ class LastGenrePlugin(plugins.BeetsPlugin): 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: + 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 + new_genres = [] # Nothing found, leave original if configured and valid. if obj.genre and self.config["keep_existing"]: From 05a1a95ee91481f0ec095f4aa351ed4ba2be96e5 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Wed, 20 Aug 2025 07:59:10 +0200 Subject: [PATCH 07/10] lastgenre: Dedup combine/resolve/label/format code --- beetsplug/lastgenre/__init__.py | 65 +++++++++++++++------------------ 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 44c443a18..2a67fa9da 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -357,9 +357,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"]: @@ -379,40 +392,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" - 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 - new_genres = [] + 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): - label = "album" - 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 - new_genres = [] + if result := _try_resolve_stage( + "album", keep_genres, new_genres + ): + return result 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 = [] @@ -427,7 +426,7 @@ 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, @@ -435,16 +434,10 @@ class LastGenrePlugin(plugins.BeetsPlugin): ) 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 - 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"]: From a1efd2836a2d3b7892be0f473b37397d746f8e2e Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Wed, 20 Aug 2025 19:09:13 +0200 Subject: [PATCH 08/10] lastgenre: Clarify keep-existing precedence in docs --- docs/plugins/lastgenre.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst index 68d4a60a7..5ebe2d721 100644 --- a/docs/plugins/lastgenre.rst +++ b/docs/plugins/lastgenre.rst @@ -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 From efa968175ba52a72113103d6dea45c9cb95a546e Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Thu, 21 Aug 2025 07:51:37 +0200 Subject: [PATCH 09/10] Changelog for #5946 --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2c5147017..00e9a9e74 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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: From 0dcf7fdc234dd68f43dae479a6c99b3bf7d4fb95 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Thu, 21 Aug 2025 08:32:47 +0200 Subject: [PATCH 10/10] lastgenre: Remove leftover/unused REPLACE constant --- beetsplug/lastgenre/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 2a67fa9da..b0808e4b9 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -42,10 +42,6 @@ PYLAST_EXCEPTIONS = ( pylast.NetworkError, ) -REPLACE = { - "\u2010": "-", -} - # Canonicalization tree processing.