diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 8fa5a68640..a835af5e4f 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -172,7 +172,47 @@ def ensure_first_value(single_field: str, list_field: str) -> None: elif list_val: setattr(m, single_field, list_val[0]) + def sync_genre_fields() -> None: + """Synchronize genre and genres fields with proper join/split logic. + + The genre field stores a joined string of all genres (for backward + compatibility with users who store multiple genres as delimited strings), + while genres is the native list representation. + + When multi_value_genres config is disabled, only the first genre is used. + """ + genre_val = getattr(m, "genre") + genres_val = getattr(m, "genres") + + # Handle None values - treat as empty + if genres_val is None: + genres_val = [] + if genre_val is None: + genre_val = "" + + if config["multi_value_genres"]: + # New behavior: sync all genres using configurable separator + separator = config["genre_separator"].get(str) + if genres_val: + # If genres list exists, join it into genre string + setattr(m, "genre", separator.join(genres_val)) + elif genre_val: + # If only genre string exists, split it into genres list + # and clean up the genre string + cleaned_genres = [ + g.strip() for g in genre_val.split(separator) if g.strip() + ] + setattr(m, "genres", cleaned_genres) + setattr(m, "genre", separator.join(cleaned_genres)) + else: + # Old behavior: only sync first value (like albumtype) + if genre_val: + setattr(m, "genres", unique_list([genre_val, *genres_val])) + elif genres_val: + setattr(m, "genre", genres_val[0]) + ensure_first_value("albumtype", "albumtypes") + sync_genre_fields() if hasattr(m, "mb_artistids"): ensure_first_value("mb_artistid", "mb_artistids") diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index b809609ea4..67a17fdc50 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -68,6 +68,7 @@ def __init__( data_source: str | None = None, data_url: str | None = None, genre: str | None = None, + genres: list[str] | None = None, media: str | None = None, **kwargs, ) -> None: @@ -83,6 +84,7 @@ def __init__( self.data_source = data_source self.data_url = data_url self.genre = genre + self.genres = genres or [] self.media = media self.update(kwargs) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index c0bab8056e..da95488c00 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -97,6 +97,10 @@ sunique: per_disc_numbering: no original_date: no artist_credit: no +multi_value_genres: yes +# Separator for joining multiple genres. Default matches lastgenre's separator. +# Use "; " or " / " if you prefer a different format. +genre_separator: ", " id3v23: no va_name: "Various Artists" paths: diff --git a/beets/library/models.py b/beets/library/models.py index cbee2a411e..d608c60d3e 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -241,6 +241,7 @@ class Album(LibModel): "albumartists_credit": types.MULTI_VALUE_DSV, "album": types.STRING, "genre": types.STRING, + "genres": types.MULTI_VALUE_DSV, "style": types.STRING, "discogs_albumid": types.INTEGER, "discogs_artistid": types.INTEGER, @@ -297,6 +298,7 @@ def _types(cls) -> dict[str, types.Type]: "albumartists_credit", "album", "genre", + "genres", "style", "discogs_albumid", "discogs_artistid", @@ -643,6 +645,7 @@ class Item(LibModel): "albumartist_credit": types.STRING, "albumartists_credit": types.MULTI_VALUE_DSV, "genre": types.STRING, + "genres": types.MULTI_VALUE_DSV, "style": types.STRING, "discogs_albumid": types.INTEGER, "discogs_artistid": types.INTEGER, diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 718e0730e6..b45492fc82 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -234,7 +234,14 @@ def __init__(self, data: JSONDict): if "artists" in data: self.artists = [(x["id"], str(x["name"])) for x in data["artists"]] if "genres" in data: - self.genres = [str(x["name"]) for x in data["genres"]] + genre_list = [str(x["name"]) for x in data["genres"]] + # Remove duplicates while preserving order + genre_list = list(dict.fromkeys(genre_list)) + if beets.config["multi_value_genres"]: + self.genres = genre_list + else: + # Even when disabled, populate with first genre for consistency + self.genres = [genre_list[0]] if genre_list else [] def artists_str(self) -> str | None: if self.artists is not None: @@ -306,11 +313,25 @@ def __init__(self, data: JSONDict): self.bpm = data.get("bpm") self.initial_key = str((data.get("key") or {}).get("shortName")) - # Use 'subgenre' and if not present, 'genre' as a fallback. + # Extract genres list from subGenres or genres if data.get("subGenres"): - self.genre = str(data["subGenres"][0].get("name")) + genre_list = [str(x.get("name")) for x in data["subGenres"]] elif data.get("genres"): - self.genre = str(data["genres"][0].get("name")) + genre_list = [str(x.get("name")) for x in data["genres"]] + else: + genre_list = [] + + # Remove duplicates while preserving order + genre_list = list(dict.fromkeys(genre_list)) + + if beets.config["multi_value_genres"]: + # New behavior: populate both genres list and joined string + separator = beets.config["genre_separator"].get(str) + self.genres = genre_list + self.genre = separator.join(genre_list) if genre_list else None + else: + # Old behavior: only populate single genre field with first value + self.genre = genre_list[0] if genre_list else None class BeatportPlugin(MetadataSourcePlugin): @@ -484,6 +505,7 @@ def _get_album_info(self, release: BeatportRelease) -> AlbumInfo: data_source=self.data_source, data_url=release.url, genre=release.genre, + genres=release.genres, year=release_date.year if release_date else None, month=release_date.month if release_date else None, day=release_date.day if release_date else None, @@ -509,6 +531,7 @@ def _get_track_info(self, track: BeatportTrack) -> TrackInfo: bpm=track.bpm, initial_key=track.initial_key, genre=track.genre, + genres=track.genres, ) def _get_artist(self, artists): diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index ea0ab951a8..b76a8e2a37 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -329,12 +329,35 @@ def _format_and_stringify(self, tags: list[str]) -> str: else: formatted = tags - return self.config["separator"].as_str().join(formatted) + # Use global genre_separator when multi_value_genres is enabled + # for consistency with sync logic, otherwise use plugin's own separator + if config["multi_value_genres"]: + separator = config["genre_separator"].get(str) + else: + separator = self.config["separator"].as_str() + + return separator.join(formatted) def _get_existing_genres(self, obj: LibModel) -> list[str]: """Return a list of genres for this Item or Album. Empty string genres are removed.""" - separator = self.config["separator"].get() + # Prefer the genres field if it exists (multi-value support) + if isinstance(obj, library.Item): + genres_list = obj.get("genres", with_album=False) + else: + genres_list = obj.get("genres") + + # If genres field exists and is not empty, use it + if genres_list: + return [g for g in genres_list if g] + + # Otherwise fall back to splitting the genre field + # Use global genre_separator when multi_value_genres is enabled + if config["multi_value_genres"]: + separator = config["genre_separator"].get(str) + else: + separator = self.config["separator"].get() + if isinstance(obj, library.Item): item_genre = obj.get("genre", with_album=False).split(separator) else: @@ -473,6 +496,17 @@ def _fetch_and_log_genre(self, obj: LibModel) -> None: obj.genre, label = self._get_genre(obj) self._log.debug("Resolved ({}): {}", label, obj.genre) + # Also populate the genres list field if multi_value_genres is enabled + if config["multi_value_genres"]: + if obj.genre: + # Use global genre_separator for consistency with sync logic + separator = config["genre_separator"].get(str) + obj.genres = [ + g.strip() for g in obj.genre.split(separator) if g.strip() + ] + else: + obj.genres = [] + ui.show_model_changes(obj, fields=["genre"], print_obj=False) @singledispatchmethod diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 2b9d5e9c26..a6e4251622 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -736,10 +736,19 @@ def album_info(self, release: JSONDict) -> beets.autotag.hooks.AlbumInfo: for source in sources: for genreitem in source: genres[genreitem["name"]] += int(genreitem["count"]) - info.genre = "; ".join( + genre_list = [ genre for genre, _count in sorted(genres.items(), key=lambda g: -g[1]) - ) + ] + + if config["multi_value_genres"]: + # New behavior: populate genres list and joined genre string + separator = config["genre_separator"].get(str) + info.genres = genre_list + info.genre = separator.join(genre_list) if genre_list else None + else: + # Old behavior: only populate single genre field with first value + info.genre = genre_list[0] if genre_list else None # We might find links to external sources (Discogs, Bandcamp, ...) external_ids = self.config["external_ids"].get() diff --git a/docs/changelog.rst b/docs/changelog.rst index 366af9ff0a..4a40bbd119 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,6 +12,24 @@ been dropped. New features: +- Add native support for multiple genres per album/track. The new ``genres`` + field stores genres as a list and is written to files as multiple individual + genre tags (e.g., separate GENRE tags for FLAC/MP3). A new + ``multi_value_genres`` config option (default: yes) controls this behavior. + When enabled, provides better interoperability with other music taggers. When + disabled, preserves the old single-genre behavior. The ``genre_separator`` + config option (default: ``", "``) allows customizing the separator used when + joining multiple genres into a single string. The default matches the + :doc:`plugins/lastgenre` plugin's separator for seamless migration. The + :doc:`plugins/musicbrainz`, :doc:`plugins/beatport`, and + :doc:`plugins/lastgenre` plugins have been updated to populate the ``genres`` + field. + + **Migration note**: Most users don't need to do anything. If you previously + used a custom ``separator`` in the lastgenre plugin (not the default ``", + "``), set ``genre_separator`` to match your custom value. Alternatively, set + ``multi_value_genres: no`` to preserve the old behavior entirely. + - :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle. - :doc:`plugins/musicbrainz`: Allow selecting tags or genres to populate the genres tag. diff --git a/docs/reference/config.rst b/docs/reference/config.rst index b4874416c2..2a20929d4d 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -306,6 +306,44 @@ Either ``yes`` or ``no``, indicating whether matched tracks and albums should use the artist credit, rather than the artist. That is, if this option is turned on, then ``artist`` will contain the artist as credited on the release. +.. _multi_value_genres: + +multi_value_genres +~~~~~~~~~~~~~~~~~~ + +Either ``yes`` or ``no`` (default: ``yes``), controlling whether to use native +support for multiple genres per album/track. When enabled, the ``genres`` field +stores genres as a list and writes them to files as multiple individual genre +tags (e.g., separate GENRE tags for FLAC/MP3). The single ``genre`` field is +maintained as a joined string for backward compatibility. When disabled, only +the first genre is used (preserving the old behavior). + +.. _genre_separator: + +genre_separator +~~~~~~~~~~~~~~~ + +Default: ``", "``. + +The separator string used when joining multiple genres into the single ``genre`` +field. This setting is only used when :ref:`multi_value_genres` is enabled. For +example, with the default separator, a track with genres ``["Rock", +"Alternative", "Indie"]`` will have ``genre`` set to ``"Rock, Alternative, +Indie"``. You can customize this to match your preferred format (e.g., ``"; "`` +or ``" / "``). + +The default (``", "``) matches the :doc:`lastgenre plugin's +` default separator for seamless migration. When +:ref:`multi_value_genres` is enabled, this global separator takes precedence +over the lastgenre plugin's ``separator`` option to ensure consistency across +all genre-related operations. + +**Custom separator migration**: If you previously used a custom (non-default) +``separator`` in the lastgenre plugin, set ``genre_separator`` to match your +custom value. You can check your existing format by running ``beet ls -f +'$genre' | head -20``. Alternatively, set ``multi_value_genres: no`` to preserve +the old behavior entirely. + .. _per_disc_numbering: per_disc_numbering diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 12ff30f8ed..7211f565ce 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -409,6 +409,7 @@ def test_sort_by_depth(self): "separator": "\u0000", "canonical": False, "prefer_specific": False, + "count": 10, }, "Blues", { @@ -567,6 +568,14 @@ def mock_fetch_artist_genre(self, obj): # Configure plugin.config.set(config_values) plugin.setup() # Loads default whitelist and canonicalization tree + + # If test specifies a separator, set it as the global genre_separator + # (when multi_value_genres is enabled, plugins use the global separator) + if "separator" in config_values: + from beets import config + + config["genre_separator"] = config_values["separator"] + item = _common.item() item.genre = item_genre diff --git a/test/test_autotag.py b/test/test_autotag.py index 8d467e5ed9..ac3b60b901 100644 --- a/test/test_autotag.py +++ b/test/test_autotag.py @@ -475,3 +475,138 @@ def test_correct_list_fields( single_val, list_val = item[single_field], item[list_field] assert (not single_val and not list_val) or single_val == list_val[0] + + +# Tests for multi-value genres functionality +class TestGenreSync: + """Test the genre/genres field synchronization.""" + + def test_sync_genres_enabled_list_to_string(self): + """When multi_value_genres is enabled, genres list joins into genre string.""" + config["multi_value_genres"] = True + + item = Item(genres=["Rock", "Alternative", "Indie"]) + correct_list_fields(item) + + assert item.genre == "Rock, Alternative, Indie" + assert item.genres == ["Rock", "Alternative", "Indie"] + + def test_sync_genres_enabled_string_to_list(self): + """When multi_value_genres is enabled, genre string splits into genres list.""" + config["multi_value_genres"] = True + + item = Item(genre="Rock, Alternative, Indie") + correct_list_fields(item) + + assert item.genre == "Rock, Alternative, Indie" + assert item.genres == ["Rock", "Alternative", "Indie"] + + def test_sync_genres_disabled_only_first(self): + """When multi_value_genres is disabled, only first genre is used.""" + config["multi_value_genres"] = False + + item = Item(genres=["Rock", "Alternative", "Indie"]) + correct_list_fields(item) + + assert item.genre == "Rock" + assert item.genres == ["Rock", "Alternative", "Indie"] + + def test_sync_genres_disabled_string_becomes_list(self): + """When multi_value_genres is disabled, genre string becomes first in list.""" + config["multi_value_genres"] = False + + item = Item(genre="Rock") + correct_list_fields(item) + + assert item.genre == "Rock" + assert item.genres == ["Rock"] + + def test_sync_genres_enabled_empty_genre(self): + """Empty genre field with multi_value_genres enabled.""" + config["multi_value_genres"] = True + + item = Item(genre="") + correct_list_fields(item) + + assert item.genre == "" + assert item.genres == [] + + def test_sync_genres_enabled_empty_genres(self): + """Empty genres list with multi_value_genres enabled.""" + config["multi_value_genres"] = True + + item = Item(genres=[]) + correct_list_fields(item) + + assert item.genre == "" + assert item.genres == [] + + def test_sync_genres_enabled_with_whitespace(self): + """Genre string with extra whitespace gets cleaned up.""" + config["multi_value_genres"] = True + + item = Item(genre="Rock, Alternative , Indie") + correct_list_fields(item) + + assert item.genres == ["Rock", "Alternative", "Indie"] + assert item.genre == "Rock, Alternative, Indie" + + def test_sync_genres_priority_list_over_string(self): + """When both genre and genres exist, genres list takes priority.""" + config["multi_value_genres"] = True + + item = Item(genre="Jazz", genres=["Rock", "Alternative"]) + correct_list_fields(item) + + # genres list should take priority and update genre string + assert item.genres == ["Rock", "Alternative"] + assert item.genre == "Rock, Alternative" + + def test_sync_genres_disabled_conflicting_values(self): + """When multi_value_genres is disabled with conflicting values.""" + config["multi_value_genres"] = False + + item = Item(genre="Jazz", genres=["Rock", "Alternative"]) + correct_list_fields(item) + + # genre string should take priority and be added to front of list + assert item.genre == "Jazz" + assert item.genres == ["Jazz", "Rock", "Alternative"] + + def test_sync_genres_none_values(self): + """Handle None values in genre/genres fields without errors.""" + config["multi_value_genres"] = True + + # Test with None genre + item = Item(genre=None, genres=["Rock"]) + correct_list_fields(item) + assert item.genres == ["Rock"] + assert item.genre == "Rock" + + # Test with None genres + item = Item(genre="Jazz", genres=None) + correct_list_fields(item) + assert item.genre == "Jazz" + assert item.genres == ["Jazz"] + + def test_sync_genres_disabled_empty_genres(self): + """Handle disabled config with empty genres list.""" + config["multi_value_genres"] = False + + item = Item(genres=[]) + correct_list_fields(item) + + # Should handle empty list without errors + assert item.genres == [] + assert item.genre == "" + + def test_sync_genres_disabled_none_genres(self): + """Handle disabled config with genres=None.""" + config["multi_value_genres"] = False + + item = Item(genres=None) + correct_list_fields(item) + + # Should handle None without errors + assert item.genres == [] + assert item.genre == "" diff --git a/test/test_library.py b/test/test_library.py index 7c05290017..271f91e026 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -688,19 +688,48 @@ def test_if_def_false_complete(self): self._assert_dest(b"/base/not_played") def test_first(self): - self.i.genres = "Pop; Rock; Classical Crossover" - self._setf("%first{$genres}") + self.i.genre = "Pop; Rock; Classical Crossover" + self._setf("%first{$genre}") self._assert_dest(b"/base/Pop") def test_first_skip(self): - self.i.genres = "Pop; Rock; Classical Crossover" - self._setf("%first{$genres,1,2}") + self.i.genre = "Pop; Rock; Classical Crossover" + self._setf("%first{$genre,1,2}") self._assert_dest(b"/base/Classical Crossover") def test_first_different_sep(self): self._setf("%first{Alice / Bob / Eve,2,0, / , & }") self._assert_dest(b"/base/Alice & Bob") + def test_first_genres_list(self): + # Test that setting genres as a list syncs to genre field properly + # and works with %first template function + from beets import config + + config["genre_separator"] = "; " + from beets.autotag import correct_list_fields + + self.i.genres = ["Pop", "Rock", "Classical Crossover"] + correct_list_fields(self.i) + # genre field should now be synced + assert self.i.genre == "Pop; Rock; Classical Crossover" + # %first should work on the synced genre field + self._setf("%first{$genre}") + self._assert_dest(b"/base/Pop") + + def test_first_genres_list_skip(self): + # Test that genres list works with %first skip parameter + from beets import config + + config["genre_separator"] = "; " + from beets.autotag import correct_list_fields + + self.i.genres = ["Pop", "Rock", "Classical Crossover"] + correct_list_fields(self.i) + # %first with skip should work on the synced genre field + self._setf("%first{$genre,1,2}") + self._assert_dest(b"/base/Classical Crossover") + class DisambiguationTest(BeetsTestCase, PathFormattingMixin): def setUp(self):