From c9b90c617ee305cb3764b39dbef1e79c10993fe1 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Mon, 30 Mar 2026 18:34:17 +0200 Subject: [PATCH 1/8] lastgenre: Move get_genre helpers to methods --- beetsplug/lastgenre/__init__.py | 63 +++++++++++++++++---------------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 71355223be..fad0f788ed 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -441,6 +441,31 @@ def _combine_resolve_and_log( combined = old + new return self._resolve_genres(combined, artist=artist) + def _configured_fallback(self) -> tuple[list[str], str]: + """Return the configured fallback genre and label.""" + if fallback := self.config["fallback"].get(): + return [fallback], "fallback" + return [], "fallback unconfigured" + + def _try_resolve_stage( + self, + stage_label: str, + keep_genres: list[str], + new_genres: list[str], + artist: str | None = None, + ) -> tuple[list[str], str] | None: + """Try to resolve genres for a given stage and log the result.""" + resolved_genres = self._combine_resolve_and_log( + keep_genres, new_genres, artist=artist + ) + if resolved_genres: + suffix = "whitelist" if self.whitelist else "any" + label = f"{stage_label}, {suffix}" + if keep_genres: + label = f"keep + {label}" + return self._format_genres(resolved_genres), label + return None + def _get_genre(self, obj: LibModel) -> tuple[list[str], str]: """Get the final genre list for an Album or Item object. @@ -461,30 +486,6 @@ def _get_genre(self, obj: LibModel) -> tuple[list[str], str]: and the whitelist feature was disabled. """ - def _fallback_stage() -> tuple[list[str], str]: - """Return the fallback genre and label.""" - if fallback := self.config["fallback"].get(): - return [fallback], "fallback" - return [], "fallback unconfigured" - - def _try_resolve_stage( - stage_label: str, - keep_genres: list[str], - new_genres: list[str], - artist: str | None = None, - ) -> tuple[list[str], str] | None: - """Try to resolve genres for a given stage and log the result.""" - resolved_genres = self._combine_resolve_and_log( - keep_genres, new_genres, artist=artist - ) - if resolved_genres: - suffix = "whitelist" if self.whitelist else "any" - label = f"{stage_label}, {suffix}" - if keep_genres: - label = f"keep + {label}" - return self._format_genres(resolved_genres), label - return None - keep_genres = [] new_genres = [] genres = self._get_existing_genres(obj) @@ -495,7 +496,7 @@ def _try_resolve_stage( # If none are found, we use the fallback (if set). if self.config["cleanup_existing"]: keep_genres = [g.lower() for g in genres] - if result := _try_resolve_stage( + if result := self._try_resolve_stage( "cleanup", keep_genres, [], @@ -503,7 +504,7 @@ def _try_resolve_stage( ): return result - return _fallback_stage() + return self._configured_fallback() # If cleanup_existing is not set, the pre-populated tags are # returned as-is. @@ -519,14 +520,14 @@ def _try_resolve_stage( # album artist, or most popular track genre. if isinstance(obj, library.Item) and "track" in self.sources: if new_genres := self.client.fetch("track", obj): - if result := _try_resolve_stage( + if result := self._try_resolve_stage( "track", keep_genres, new_genres, artist=obj.artist ): return result if "album" in self.sources: if new_genres := self.client.fetch("album", obj): - if result := _try_resolve_stage( + if result := self._try_resolve_stage( "album", keep_genres, new_genres, artist=obj.albumartist ): return result @@ -583,7 +584,7 @@ def _try_resolve_stage( ) if new_genres: - if result := _try_resolve_stage( + if result := self._try_resolve_stage( stage_label, keep_genres, new_genres, artist=stage_artist ): return result @@ -595,12 +596,12 @@ def _try_resolve_stage( return valid_genres, "original fallback" # If the original genre doesn't match a whitelisted genre, check # if we can canonicalize it to find a matching, whitelisted genre! - if result := _try_resolve_stage( + if result := self._try_resolve_stage( "original fallback", keep_genres, [], artist=artist ): return result - return _fallback_stage() + return self._configured_fallback() # Beets plugin hooks and CLI. From 03632d74743c9490e83ea1dc668c6b2cd56a0340 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 26 Jul 2026 20:12:06 +0200 Subject: [PATCH 2/8] lastgenre: Conditional assign keep_genres (force mode) --- beetsplug/lastgenre/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index fad0f788ed..bc42acbe3c 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -510,11 +510,11 @@ def _get_genre(self, obj: LibModel) -> tuple[list[str], str]: # returned as-is. return genres, "keep any, no-force" - if self.config["force"]: - # Force doesn't keep any unless keep_existing is set. - # Whitelist validation is handled in _resolve_genres. - if self.config["keep_existing"]: - keep_genres = [g.lower() for g in genres] + keep_genres = ( + [g.lower() for g in genres] + if self.config["keep_existing"] and self.config["force"] + else [] + ) # Run through stages: track, album, artist, # album artist, or most popular track genre. From de742d31a0b97438154678a6c73d895f7474d93d Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Mon, 30 Mar 2026 18:45:29 +0200 Subject: [PATCH 3/8] lastgenre: Extract existing genre stage --- beetsplug/lastgenre/__init__.py | 43 ++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index bc42acbe3c..b0675b16ac 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -466,6 +466,28 @@ def _try_resolve_stage( return self._format_genres(resolved_genres), label return None + def _try_resolve_existing_genres( + self, obj: LibModel, genres: list[str] + ) -> tuple[list[str], str] | None: + """Handle existing genres when not forcing. + + Return ``None`` if forcing or no genres exist. Otherwise, clean up existing + genres if enabled, falling back if needed, or return them unchanged. + """ + if not genres or self.config["force"]: + return None + + if self.config["cleanup_existing"]: + keep_genres = [g.lower() for g in genres] + if result := self._try_resolve_stage( + "cleanup", keep_genres, [], artist=self._artist_for_filter(obj) + ): + return result + + return self._configured_fallback() + + return genres, "keep any, no-force" + def _get_genre(self, obj: LibModel) -> tuple[list[str], str]: """Get the final genre list for an Album or Item object. @@ -490,25 +512,8 @@ def _get_genre(self, obj: LibModel) -> tuple[list[str], str]: new_genres = [] genres = self._get_existing_genres(obj) - if genres and not self.config["force"]: - # Without force, but cleanup_existing enabled, we attempt - # to canonicalize pre-populated tags before returning them. - # If none are found, we use the fallback (if set). - if self.config["cleanup_existing"]: - keep_genres = [g.lower() for g in genres] - if result := self._try_resolve_stage( - "cleanup", - keep_genres, - [], - artist=self._artist_for_filter(obj), - ): - return result - - return self._configured_fallback() - - # If cleanup_existing is not set, the pre-populated tags are - # returned as-is. - return genres, "keep any, no-force" + if result := self._try_resolve_existing_genres(obj, genres): + return result keep_genres = ( [g.lower() for g in genres] From ca8da885a6d325f5c497810d7829576e591962b9 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Mon, 27 Jul 2026 08:42:08 +0200 Subject: [PATCH 4/8] lastgenre: Improve _try_resolve_stage docstring --- beetsplug/lastgenre/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index b0675b16ac..96369cbce8 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -454,7 +454,12 @@ def _try_resolve_stage( new_genres: list[str], artist: str | None = None, ) -> tuple[list[str], str] | None: - """Try to resolve genres for a given stage and log the result.""" + """Try to resolve genres for a given stage and log the result. + + If any newly fetched genres and/or existing genres are resolved, return + a tuple of the resolved genres and a label describing the source and + filtering applied. Otherwise, return ``None``. + """ resolved_genres = self._combine_resolve_and_log( keep_genres, new_genres, artist=artist ) From c38e5ba021588ced0784158e824a31049762fa85 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Tue, 28 Jul 2026 07:11:19 +0200 Subject: [PATCH 5/8] lastgenre: Name vars resolved instead of result --- beetsplug/lastgenre/__init__.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 96369cbce8..bf91b987ba 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -517,8 +517,8 @@ def _get_genre(self, obj: LibModel) -> tuple[list[str], str]: new_genres = [] genres = self._get_existing_genres(obj) - if result := self._try_resolve_existing_genres(obj, genres): - return result + if resolved := self._try_resolve_existing_genres(obj, genres): + return resolved keep_genres = ( [g.lower() for g in genres] @@ -530,17 +530,17 @@ def _get_genre(self, obj: LibModel) -> tuple[list[str], str]: # album artist, or most popular track genre. if isinstance(obj, library.Item) and "track" in self.sources: if new_genres := self.client.fetch("track", obj): - if result := self._try_resolve_stage( + if resolved := self._try_resolve_stage( "track", keep_genres, new_genres, artist=obj.artist ): - return result + return resolved if "album" in self.sources: if new_genres := self.client.fetch("album", obj): - if result := self._try_resolve_stage( + if resolved := self._try_resolve_stage( "album", keep_genres, new_genres, artist=obj.albumartist ): - return result + return resolved if "artist" in self.sources: new_genres = [] @@ -594,10 +594,10 @@ def _get_genre(self, obj: LibModel) -> tuple[list[str], str]: ) if new_genres: - if result := self._try_resolve_stage( + if resolved := self._try_resolve_stage( stage_label, keep_genres, new_genres, artist=stage_artist ): - return result + return resolved # Nothing found, leave original if configured and valid. if genres and self.config["keep_existing"].get(): @@ -606,10 +606,10 @@ def _get_genre(self, obj: LibModel) -> tuple[list[str], str]: return valid_genres, "original fallback" # If the original genre doesn't match a whitelisted genre, check # if we can canonicalize it to find a matching, whitelisted genre! - if result := self._try_resolve_stage( + if resolved := self._try_resolve_stage( "original fallback", keep_genres, [], artist=artist ): - return result + return resolved return self._configured_fallback() From 23f5511fb10a98cd84b7c8f9fe09462f5ddec466 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Tue, 28 Jul 2026 07:22:15 +0200 Subject: [PATCH 6/8] lastgenre: Extract complex artist stage to methods --- beetsplug/lastgenre/__init__.py | 116 ++++++++++++++++++-------------- 1 file changed, 65 insertions(+), 51 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index bf91b987ba..7cbcdc84d9 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -493,6 +493,68 @@ def _try_resolve_existing_genres( return genres, "keep any, no-force" + def _fetch_va_genres(self, album: Album) -> list[str]: + """Fetch the most popular track or artist genre for a Various Artists album.""" + item_genres = [] + for item in album.items(): + item_genre = None + if "track" in self.sources: + item_genre = self.client.fetch("track", item) + if not item_genre: + item_genre = self.client.fetch("artist", item) + if item_genre: + item_genres += item_genre + + if item_genres: + most_popular, rank = plurality(item_genres) + self._log.debug( + 'Most popular track genre "{}" ({}) for VA album.', + most_popular, + rank, + ) + return [most_popular] + + return [] + + def _fetch_artist_stage( + self, obj: LibModel + ) -> tuple[str, list[str], str | None]: + """Fetch artist genres for an Item or Album object. + + Return a tuple of ``(stage_label, genres, stage_artist)``. + """ + if isinstance(obj, library.Item): + return "artist", self.client.fetch("artist", obj), obj.artist + + if obj.albumartist != config["va_name"].as_str(): + new_genres = self.client.fetch("album_artist", obj) + if new_genres: + return "album artist", new_genres, obj.albumartist + + self._log.extra_debug( + 'No album artist genre found for "{}", ' + "trying multi-valued field...", + obj.albumartist, + ) + for albumartist in obj.albumartists: + self._log.extra_debug( + 'Fetching artist genre for "{}"', albumartist + ) + new_genres += self.client.fetch( + "album_artist", obj, albumartist + ) + if new_genres: + # Already filtered per-artist in client + return "multi-valued album artist", new_genres, None + return "album artist", [], None + + # For "Various Artists", pick the most popular track genre. + assert isinstance(obj, Album) # Type narrowing for mypy + if va_genres := self._fetch_va_genres(obj): + return "most popular track", va_genres, None + + return "most popular track", [], None + def _get_genre(self, obj: LibModel) -> tuple[list[str], str]: """Get the final genre list for an Album or Item object. @@ -514,7 +576,6 @@ def _get_genre(self, obj: LibModel) -> tuple[list[str], str]: """ keep_genres = [] - new_genres = [] genres = self._get_existing_genres(obj) if resolved := self._try_resolve_existing_genres(obj, genres): @@ -543,56 +604,9 @@ def _get_genre(self, obj: LibModel) -> tuple[list[str], str]: return resolved if "artist" in self.sources: - new_genres = [] - stage_artist: str | None = None - if isinstance(obj, library.Item): - new_genres = self.client.fetch("artist", obj) - stage_label = "artist" - stage_artist = obj.artist - elif obj.albumartist != config["va_name"].as_str(): - new_genres = self.client.fetch("album_artist", obj) - stage_label = "album artist" - stage_artist = obj.albumartist - if not new_genres: - self._log.extra_debug( - 'No album artist genre found for "{}", ' - "trying multi-valued field...", - obj.albumartist, - ) - for albumartist in obj.albumartists: - self._log.extra_debug( - 'Fetching artist genre for "{}"', albumartist - ) - new_genres += self.client.fetch( - "album_artist", obj, albumartist - ) - if new_genres: - stage_label = "multi-valued album artist" - stage_artist = ( - None # Already filtered per-artist in client - ) - 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.client.fetch("track", item) - if not item_genre: - item_genre = self.client.fetch("artist", item) - if item_genre: - item_genres += item_genre - if item_genres: - most_popular, rank = plurality(item_genres) - new_genres = [most_popular] - stage_label = "most popular track" - self._log.debug( - 'Most popular track genre "{}" ({}) for VA album.', - most_popular, - rank, - ) - + stage_label, new_genres, stage_artist = self._fetch_artist_stage( + obj + ) if new_genres: if resolved := self._try_resolve_stage( stage_label, keep_genres, new_genres, artist=stage_artist From b63159bc6d4af7baa81edd597a4fcf52dadcfd58 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Thu, 30 Jul 2026 06:46:40 +0200 Subject: [PATCH 7/8] lastgenre: Extract original fallback stage to method --- beetsplug/lastgenre/__init__.py | 36 +++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 7cbcdc84d9..3db12c2f98 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -493,6 +493,27 @@ def _try_resolve_existing_genres( return genres, "keep any, no-force" + def _try_resolve_original_fallback( + self, obj: LibModel, genres: list[str], keep_genres: list[str] + ) -> tuple[list[str], str] | None: + """Attempt to fall back to existing original genres if configured. + + ``genres`` are the original unchanged values and are checked as-is + first, then ``keep_genres`` are used for a lowercased canonicalization + retry. + """ + if genres and self.config["keep_existing"].get(): + artist = self._artist_for_filter(obj) + if valid_genres := self._filter_valid(genres, artist=artist): + return valid_genres, "original fallback" + # If the original genre doesn't match a whitelisted genre, check + # if we can canonicalize it to find a matching, whitelisted genre! + if resolved := self._try_resolve_stage( + "original fallback", keep_genres, [], artist=artist + ): + return resolved + return None + def _fetch_va_genres(self, album: Album) -> list[str]: """Fetch the most popular track or artist genre for a Various Artists album.""" item_genres = [] @@ -613,17 +634,10 @@ def _get_genre(self, obj: LibModel) -> tuple[list[str], str]: ): return resolved - # Nothing found, leave original if configured and valid. - if genres and self.config["keep_existing"].get(): - artist = self._artist_for_filter(obj) - if valid_genres := self._filter_valid(genres, artist=artist): - return valid_genres, "original fallback" - # If the original genre doesn't match a whitelisted genre, check - # if we can canonicalize it to find a matching, whitelisted genre! - if resolved := self._try_resolve_stage( - "original fallback", keep_genres, [], artist=artist - ): - return resolved + if resolved := self._try_resolve_original_fallback( + obj, genres, keep_genres + ): + return resolved return self._configured_fallback() From b4a7cafb27a5fb9b9abc0c03110feffd7685c87f Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Thu, 30 Jul 2026 07:09:14 +0200 Subject: [PATCH 8/8] lastgenre: Rename genres to existing_genres --- beetsplug/lastgenre/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 3db12c2f98..eb612272ab 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -597,13 +597,13 @@ def _get_genre(self, obj: LibModel) -> tuple[list[str], str]: """ keep_genres = [] - genres = self._get_existing_genres(obj) + existing_genres = self._get_existing_genres(obj) - if resolved := self._try_resolve_existing_genres(obj, genres): + if resolved := self._try_resolve_existing_genres(obj, existing_genres): return resolved keep_genres = ( - [g.lower() for g in genres] + [g.lower() for g in existing_genres] if self.config["keep_existing"] and self.config["force"] else [] ) @@ -635,7 +635,7 @@ def _get_genre(self, obj: LibModel) -> tuple[list[str], str]: return resolved if resolved := self._try_resolve_original_fallback( - obj, genres, keep_genres + obj, existing_genres, keep_genres ): return resolved