This commit is contained in:
dunkla 2025-12-04 10:43:01 +01:00 committed by GitHub
commit a54094356b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 356 additions and 12 deletions

View file

@ -166,7 +166,47 @@ def correct_list_fields(m: LibModel) -> None:
elif list_val: elif list_val:
setattr(m, single_field, list_val[0]) 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") ensure_first_value("albumtype", "albumtypes")
sync_genre_fields()
if hasattr(m, "mb_artistids"): if hasattr(m, "mb_artistids"):
ensure_first_value("mb_artistid", "mb_artistids") ensure_first_value("mb_artistid", "mb_artistids")

View file

@ -68,6 +68,7 @@ class Info(AttrDict[Any]):
data_source: str | None = None, data_source: str | None = None,
data_url: str | None = None, data_url: str | None = None,
genre: str | None = None, genre: str | None = None,
genres: list[str] | None = None,
media: str | None = None, media: str | None = None,
**kwargs, **kwargs,
) -> None: ) -> None:
@ -83,6 +84,7 @@ class Info(AttrDict[Any]):
self.data_source = data_source self.data_source = data_source
self.data_url = data_url self.data_url = data_url
self.genre = genre self.genre = genre
self.genres = genres or []
self.media = media self.media = media
self.update(kwargs) self.update(kwargs)

View file

@ -97,6 +97,10 @@ sunique:
per_disc_numbering: no per_disc_numbering: no
original_date: no original_date: no
artist_credit: 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 id3v23: no
va_name: "Various Artists" va_name: "Various Artists"
paths: paths:

View file

@ -241,6 +241,7 @@ class Album(LibModel):
"albumartists_credit": types.MULTI_VALUE_DSV, "albumartists_credit": types.MULTI_VALUE_DSV,
"album": types.STRING, "album": types.STRING,
"genre": types.STRING, "genre": types.STRING,
"genres": types.MULTI_VALUE_DSV,
"style": types.STRING, "style": types.STRING,
"discogs_albumid": types.INTEGER, "discogs_albumid": types.INTEGER,
"discogs_artistid": types.INTEGER, "discogs_artistid": types.INTEGER,
@ -297,6 +298,7 @@ class Album(LibModel):
"albumartists_credit", "albumartists_credit",
"album", "album",
"genre", "genre",
"genres",
"style", "style",
"discogs_albumid", "discogs_albumid",
"discogs_artistid", "discogs_artistid",
@ -643,6 +645,7 @@ class Item(LibModel):
"albumartist_credit": types.STRING, "albumartist_credit": types.STRING,
"albumartists_credit": types.MULTI_VALUE_DSV, "albumartists_credit": types.MULTI_VALUE_DSV,
"genre": types.STRING, "genre": types.STRING,
"genres": types.MULTI_VALUE_DSV,
"style": types.STRING, "style": types.STRING,
"discogs_albumid": types.INTEGER, "discogs_albumid": types.INTEGER,
"discogs_artistid": types.INTEGER, "discogs_artistid": types.INTEGER,

View file

@ -234,7 +234,14 @@ class BeatportObject:
if "artists" in data: if "artists" in data:
self.artists = [(x["id"], str(x["name"])) for x in data["artists"]] self.artists = [(x["id"], str(x["name"])) for x in data["artists"]]
if "genres" in data: 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: def artists_str(self) -> str | None:
if self.artists is not None: if self.artists is not None:
@ -306,11 +313,25 @@ class BeatportTrack(BeatportObject):
self.bpm = data.get("bpm") self.bpm = data.get("bpm")
self.initial_key = str((data.get("key") or {}).get("shortName")) 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"): 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"): 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): class BeatportPlugin(MetadataSourcePlugin):
@ -484,6 +505,7 @@ class BeatportPlugin(MetadataSourcePlugin):
data_source=self.data_source, data_source=self.data_source,
data_url=release.url, data_url=release.url,
genre=release.genre, genre=release.genre,
genres=release.genres,
year=release_date.year if release_date else None, year=release_date.year if release_date else None,
month=release_date.month if release_date else None, month=release_date.month if release_date else None,
day=release_date.day if release_date else None, day=release_date.day if release_date else None,
@ -509,6 +531,7 @@ class BeatportPlugin(MetadataSourcePlugin):
bpm=track.bpm, bpm=track.bpm,
initial_key=track.initial_key, initial_key=track.initial_key,
genre=track.genre, genre=track.genre,
genres=track.genres,
) )
def _get_artist(self, artists): def _get_artist(self, artists):

View file

@ -329,12 +329,35 @@ class LastGenrePlugin(plugins.BeetsPlugin):
else: else:
formatted = tags 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]: def _get_existing_genres(self, obj: LibModel) -> list[str]:
"""Return a list of genres for this Item or Album. Empty string genres """Return a list of genres for this Item or Album. Empty string genres
are removed.""" 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): if isinstance(obj, library.Item):
item_genre = obj.get("genre", with_album=False).split(separator) item_genre = obj.get("genre", with_album=False).split(separator)
else: else:
@ -473,6 +496,17 @@ class LastGenrePlugin(plugins.BeetsPlugin):
obj.genre, label = self._get_genre(obj) obj.genre, label = self._get_genre(obj)
self._log.debug("Resolved ({}): {}", label, obj.genre) 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) ui.show_model_changes(obj, fields=["genre"], print_obj=False)
@singledispatchmethod @singledispatchmethod

View file

@ -738,10 +738,19 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
for source in sources: for source in sources:
for genreitem in source: for genreitem in source:
genres[genreitem["name"]] += int(genreitem["count"]) genres[genreitem["name"]] += int(genreitem["count"])
info.genre = "; ".join( genre_list = [
genre genre
for genre, _count in sorted(genres.items(), key=lambda g: -g[1]) 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, ...) # We might find links to external sources (Discogs, Bandcamp, ...)
external_ids = self.config["external_ids"].get() external_ids = self.config["external_ids"].get()

View file

@ -12,6 +12,24 @@ been dropped.
New features: 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/ftintitle`: Added argument for custom feat. words in ftintitle.
- :doc:`plugins/ftintitle`: Added album template value ``album_artist_no_feat``. - :doc:`plugins/ftintitle`: Added album template value ``album_artist_no_feat``.
- :doc:`plugins/musicbrainz`: Allow selecting tags or genres to populate the - :doc:`plugins/musicbrainz`: Allow selecting tags or genres to populate the

View file

@ -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 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. 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
</plugins/lastgenre>` 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:
per_disc_numbering per_disc_numbering

View file

@ -409,6 +409,7 @@ class LastGenrePluginTest(PluginTestCase):
"separator": "\u0000", "separator": "\u0000",
"canonical": False, "canonical": False,
"prefer_specific": False, "prefer_specific": False,
"count": 10,
}, },
"Blues", "Blues",
{ {
@ -567,6 +568,14 @@ def test_get_genre(config_values, item_genre, mock_genres, expected_result):
# Configure # Configure
plugin.config.set(config_values) plugin.config.set(config_values)
plugin.setup() # Loads default whitelist and canonicalization tree 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 = _common.item()
item.genre = item_genre item.genre = item_genre

View file

@ -475,3 +475,138 @@ def test_correct_list_fields(
single_val, list_val = item[single_field], item[list_field] single_val, list_val = item[single_field], item[list_field]
assert (not single_val and not list_val) or single_val == list_val[0] 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 == ""

View file

@ -688,19 +688,48 @@ class DestinationFunctionTest(BeetsTestCase, PathFormattingMixin):
self._assert_dest(b"/base/not_played") self._assert_dest(b"/base/not_played")
def test_first(self): def test_first(self):
self.i.genres = "Pop; Rock; Classical Crossover" self.i.genre = "Pop; Rock; Classical Crossover"
self._setf("%first{$genres}") self._setf("%first{$genre}")
self._assert_dest(b"/base/Pop") self._assert_dest(b"/base/Pop")
def test_first_skip(self): def test_first_skip(self):
self.i.genres = "Pop; Rock; Classical Crossover" self.i.genre = "Pop; Rock; Classical Crossover"
self._setf("%first{$genres,1,2}") self._setf("%first{$genre,1,2}")
self._assert_dest(b"/base/Classical Crossover") self._assert_dest(b"/base/Classical Crossover")
def test_first_different_sep(self): def test_first_different_sep(self):
self._setf("%first{Alice / Bob / Eve,2,0, / , & }") self._setf("%first{Alice / Bob / Eve,2,0, / , & }")
self._assert_dest(b"/base/Alice & Bob") 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): class DisambiguationTest(BeetsTestCase, PathFormattingMixin):
def setUp(self): def setUp(self):