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:
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")

View file

@ -68,6 +68,7 @@ class Info(AttrDict[Any]):
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 @@ class Info(AttrDict[Any]):
self.data_source = data_source
self.data_url = data_url
self.genre = genre
self.genres = genres or []
self.media = media
self.update(kwargs)

View file

@ -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:

View file

@ -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 @@ class Album(LibModel):
"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,

View file

@ -234,7 +234,14 @@ class BeatportObject:
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 @@ class BeatportTrack(BeatportObject):
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 @@ class BeatportPlugin(MetadataSourcePlugin):
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 @@ class BeatportPlugin(MetadataSourcePlugin):
bpm=track.bpm,
initial_key=track.initial_key,
genre=track.genre,
genres=track.genres,
)
def _get_artist(self, artists):

View file

@ -329,12 +329,35 @@ class LastGenrePlugin(plugins.BeetsPlugin):
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."""
# 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 @@ class LastGenrePlugin(plugins.BeetsPlugin):
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

View file

@ -738,10 +738,19 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
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()

View file

@ -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/ftintitle`: Added album template value ``album_artist_no_feat``.
- :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
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

View file

@ -409,6 +409,7 @@ class LastGenrePluginTest(PluginTestCase):
"separator": "\u0000",
"canonical": False,
"prefer_specific": False,
"count": 10,
},
"Blues",
{
@ -567,6 +568,14 @@ def test_get_genre(config_values, item_genre, mock_genres, expected_result):
# 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

View file

@ -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 == ""

View file

@ -688,19 +688,48 @@ class DestinationFunctionTest(BeetsTestCase, PathFormattingMixin):
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):