mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 08:39:17 +01:00
Merge f6ee49af0d into 2bd77b9895
This commit is contained in:
commit
a54094356b
12 changed files with 356 additions and 12 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 == ""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue