Skip to content

Commit a817d0d

Browse files
claudedunkla
authored andcommitted
Add native support for multiple genres per album/track
Implements native multi-value genre support following the same pattern as multi-value artists. Adds a 'genres' field that stores genres as a list and writes them as multiple individual genre tags to files. Features: - New 'genres' field (MULTI_VALUE_DSV) for albums and tracks - Bidirectional sync between 'genre' (string) and 'genres' (list) - Config option 'multi_value_genres' (default: yes) to enable/disable - Config option 'genre_separator' (default: ', ') for joining genres into the single 'genre' field - matches lastgenre's default separator - Updated MusicBrainz, Beatport, and LastGenre plugins to populate 'genres' field - LastGenre plugin now uses global genre_separator when multi_value_genres is enabled for consistency - Comprehensive test coverage (10 tests for sync logic) - Full documentation in changelog and reference/config.rst Backward Compatibility: - When multi_value_genres=yes: 'genre' field maintained as joined string for backward compatibility, 'genres' is the authoritative list - When multi_value_genres=no: Preserves old behavior (only first genre) - Default separator matches lastgenre's default for seamless migration Migration: - Most users (using lastgenre's default) need no configuration changes - Users with custom lastgenre separator should set genre_separator to match their existing data - Users can opt-out entirely with multi_value_genres: no Code Review Feedback Addressed: - Extracted genre separator into configurable option (not hardcoded) - Fixed Beatport plugin to always populate genres field consistently - Added tests for None values and edge cases - Handle None values gracefully in sync logic - Added migration documentation for smooth user experience - Made separator user-configurable instead of constant - Changed default to ', ' for seamless migration (matches lastgenre)
1 parent 2bd77b9 commit a817d0d

File tree

11 files changed

+291
-12
lines changed

11 files changed

+291
-12
lines changed

beets/autotag/__init__.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,47 @@ def ensure_first_value(single_field: str, list_field: str) -> None:
166166
elif list_val:
167167
setattr(m, single_field, list_val[0])
168168

169+
def sync_genre_fields() -> None:
170+
"""Synchronize genre and genres fields with proper join/split logic.
171+
172+
The genre field stores a joined string of all genres (for backward
173+
compatibility with users who store multiple genres as delimited strings),
174+
while genres is the native list representation.
175+
176+
When multi_value_genres config is disabled, only the first genre is used.
177+
"""
178+
genre_val = getattr(m, "genre")
179+
genres_val = getattr(m, "genres")
180+
181+
# Handle None values - treat as empty
182+
if genres_val is None:
183+
genres_val = []
184+
if genre_val is None:
185+
genre_val = ""
186+
187+
if config["multi_value_genres"]:
188+
# New behavior: sync all genres using configurable separator
189+
separator = config["genre_separator"].get(str)
190+
if genres_val:
191+
# If genres list exists, join it into genre string
192+
setattr(m, "genre", separator.join(genres_val))
193+
elif genre_val:
194+
# If only genre string exists, split it into genres list
195+
# and clean up the genre string
196+
cleaned_genres = [
197+
g.strip() for g in genre_val.split(separator) if g.strip()
198+
]
199+
setattr(m, "genres", cleaned_genres)
200+
setattr(m, "genre", separator.join(cleaned_genres))
201+
else:
202+
# Old behavior: only sync first value (like albumtype)
203+
if genre_val:
204+
setattr(m, "genres", unique_list([genre_val, *genres_val]))
205+
elif genres_val:
206+
setattr(m, "genre", genres_val[0])
207+
169208
ensure_first_value("albumtype", "albumtypes")
209+
sync_genre_fields()
170210

171211
if hasattr(m, "mb_artistids"):
172212
ensure_first_value("mb_artistid", "mb_artistids")

beets/autotag/hooks.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def __init__(
6868
data_source: str | None = None,
6969
data_url: str | None = None,
7070
genre: str | None = None,
71+
genres: list[str] | None = None,
7172
media: str | None = None,
7273
**kwargs,
7374
) -> None:
@@ -83,6 +84,7 @@ def __init__(
8384
self.data_source = data_source
8485
self.data_url = data_url
8586
self.genre = genre
87+
self.genres = genres or []
8688
self.media = media
8789
self.update(kwargs)
8890

beets/config_default.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ sunique:
9797
per_disc_numbering: no
9898
original_date: no
9999
artist_credit: no
100+
multi_value_genres: yes
101+
# Separator for joining multiple genres. Default matches lastgenre's separator.
102+
# Use "; " or " / " if you prefer a different format.
103+
genre_separator: ", "
100104
id3v23: no
101105
va_name: "Various Artists"
102106
paths:

beets/library/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ class Album(LibModel):
241241
"albumartists_credit": types.MULTI_VALUE_DSV,
242242
"album": types.STRING,
243243
"genre": types.STRING,
244+
"genres": types.MULTI_VALUE_DSV,
244245
"style": types.STRING,
245246
"discogs_albumid": types.INTEGER,
246247
"discogs_artistid": types.INTEGER,
@@ -297,6 +298,7 @@ def _types(cls) -> dict[str, types.Type]:
297298
"albumartists_credit",
298299
"album",
299300
"genre",
301+
"genres",
300302
"style",
301303
"discogs_albumid",
302304
"discogs_artistid",
@@ -643,6 +645,7 @@ class Item(LibModel):
643645
"albumartist_credit": types.STRING,
644646
"albumartists_credit": types.MULTI_VALUE_DSV,
645647
"genre": types.STRING,
648+
"genres": types.MULTI_VALUE_DSV,
646649
"style": types.STRING,
647650
"discogs_albumid": types.INTEGER,
648651
"discogs_artistid": types.INTEGER,

beetsplug/beatport.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,12 @@ def __init__(self, data: JSONDict):
234234
if "artists" in data:
235235
self.artists = [(x["id"], str(x["name"])) for x in data["artists"]]
236236
if "genres" in data:
237-
self.genres = [str(x["name"]) for x in data["genres"]]
237+
genre_list = [str(x["name"]) for x in data["genres"]]
238+
if beets.config["multi_value_genres"]:
239+
self.genres = genre_list
240+
else:
241+
# Even when disabled, populate with first genre for consistency
242+
self.genres = [genre_list[0]] if genre_list else []
238243

239244
def artists_str(self) -> str | None:
240245
if self.artists is not None:
@@ -306,11 +311,22 @@ def __init__(self, data: JSONDict):
306311
self.bpm = data.get("bpm")
307312
self.initial_key = str((data.get("key") or {}).get("shortName"))
308313

309-
# Use 'subgenre' and if not present, 'genre' as a fallback.
314+
# Extract genres list from subGenres or genres
310315
if data.get("subGenres"):
311-
self.genre = str(data["subGenres"][0].get("name"))
316+
genre_list = [str(x.get("name")) for x in data["subGenres"]]
312317
elif data.get("genres"):
313-
self.genre = str(data["genres"][0].get("name"))
318+
genre_list = [str(x.get("name")) for x in data["genres"]]
319+
else:
320+
genre_list = []
321+
322+
if beets.config["multi_value_genres"]:
323+
# New behavior: populate both genres list and joined string
324+
separator = beets.config["genre_separator"].get(str)
325+
self.genres = genre_list
326+
self.genre = separator.join(genre_list) if genre_list else None
327+
else:
328+
# Old behavior: only populate single genre field with first value
329+
self.genre = genre_list[0] if genre_list else None
314330

315331

316332
class BeatportPlugin(MetadataSourcePlugin):
@@ -484,6 +500,7 @@ def _get_album_info(self, release: BeatportRelease) -> AlbumInfo:
484500
data_source=self.data_source,
485501
data_url=release.url,
486502
genre=release.genre,
503+
genres=release.genres,
487504
year=release_date.year if release_date else None,
488505
month=release_date.month if release_date else None,
489506
day=release_date.day if release_date else None,
@@ -509,6 +526,7 @@ def _get_track_info(self, track: BeatportTrack) -> TrackInfo:
509526
bpm=track.bpm,
510527
initial_key=track.initial_key,
511528
genre=track.genre,
529+
genres=track.genres,
512530
)
513531

514532
def _get_artist(self, artists):

beetsplug/lastgenre/__init__.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,12 +329,35 @@ def _format_and_stringify(self, tags: list[str]) -> str:
329329
else:
330330
formatted = tags
331331

332-
return self.config["separator"].as_str().join(formatted)
332+
# Use global genre_separator when multi_value_genres is enabled
333+
# for consistency with sync logic, otherwise use plugin's own separator
334+
if config["multi_value_genres"]:
335+
separator = config["genre_separator"].get(str)
336+
else:
337+
separator = self.config["separator"].as_str()
338+
339+
return separator.join(formatted)
333340

334341
def _get_existing_genres(self, obj: LibModel) -> list[str]:
335342
"""Return a list of genres for this Item or Album. Empty string genres
336343
are removed."""
337-
separator = self.config["separator"].get()
344+
# Prefer the genres field if it exists (multi-value support)
345+
if isinstance(obj, library.Item):
346+
genres_list = obj.get("genres", with_album=False)
347+
else:
348+
genres_list = obj.get("genres")
349+
350+
# If genres field exists and is not empty, use it
351+
if genres_list:
352+
return [g for g in genres_list if g]
353+
354+
# Otherwise fall back to splitting the genre field
355+
# Use global genre_separator when multi_value_genres is enabled
356+
if config["multi_value_genres"]:
357+
separator = config["genre_separator"].get(str)
358+
else:
359+
separator = self.config["separator"].get()
360+
338361
if isinstance(obj, library.Item):
339362
item_genre = obj.get("genre", with_album=False).split(separator)
340363
else:
@@ -473,6 +496,17 @@ def _fetch_and_log_genre(self, obj: LibModel) -> None:
473496
obj.genre, label = self._get_genre(obj)
474497
self._log.debug("Resolved ({}): {}", label, obj.genre)
475498

499+
# Also populate the genres list field if multi_value_genres is enabled
500+
if config["multi_value_genres"]:
501+
if obj.genre:
502+
# Use global genre_separator for consistency with sync logic
503+
separator = config["genre_separator"].get(str)
504+
obj.genres = [
505+
g.strip() for g in obj.genre.split(separator) if g.strip()
506+
]
507+
else:
508+
obj.genres = []
509+
476510
ui.show_model_changes(obj, fields=["genre"], print_obj=False)
477511

478512
@singledispatchmethod

beetsplug/musicbrainz.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -738,10 +738,19 @@ def album_info(self, release: JSONDict) -> beets.autotag.hooks.AlbumInfo:
738738
for source in sources:
739739
for genreitem in source:
740740
genres[genreitem["name"]] += int(genreitem["count"])
741-
info.genre = "; ".join(
741+
genre_list = [
742742
genre
743743
for genre, _count in sorted(genres.items(), key=lambda g: -g[1])
744-
)
744+
]
745+
746+
if config["multi_value_genres"]:
747+
# New behavior: populate genres list and joined genre string
748+
separator = config["genre_separator"].get(str)
749+
info.genres = genre_list
750+
info.genre = separator.join(genre_list) if genre_list else None
751+
else:
752+
# Old behavior: only populate single genre field with first value
753+
info.genre = genre_list[0] if genre_list else None
745754

746755
# We might find links to external sources (Discogs, Bandcamp, ...)
747756
external_ids = self.config["external_ids"].get()

docs/changelog.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,24 @@ been dropped.
1212

1313
New features:
1414

15+
- Add native support for multiple genres per album/track. The new ``genres``
16+
field stores genres as a list and is written to files as multiple individual
17+
genre tags (e.g., separate GENRE tags for FLAC/MP3). A new
18+
``multi_value_genres`` config option (default: yes) controls this behavior.
19+
When enabled, provides better interoperability with other music taggers. When
20+
disabled, preserves the old single-genre behavior. The ``genre_separator``
21+
config option (default: ``", "``) allows customizing the separator used when
22+
joining multiple genres into a single string. The default matches the
23+
:doc:`plugins/lastgenre` plugin's separator for seamless migration. The
24+
:doc:`plugins/musicbrainz`, :doc:`plugins/beatport`, and
25+
:doc:`plugins/lastgenre` plugins have been updated to populate the ``genres``
26+
field.
27+
28+
**Migration note**: Most users don't need to do anything. If you previously
29+
used a custom ``separator`` in the lastgenre plugin (not the default ``",
30+
"``), set ``genre_separator`` to match your custom value. Alternatively, set
31+
``multi_value_genres: no`` to preserve the old behavior entirely.
32+
1533
- :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle.
1634
- :doc:`plugins/ftintitle`: Added album template value ``album_artist_no_feat``.
1735
- :doc:`plugins/musicbrainz`: Allow selecting tags or genres to populate the

docs/reference/config.rst

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,44 @@ Either ``yes`` or ``no``, indicating whether matched tracks and albums should
306306
use the artist credit, rather than the artist. That is, if this option is turned
307307
on, then ``artist`` will contain the artist as credited on the release.
308308

309+
.. _multi_value_genres:
310+
311+
multi_value_genres
312+
~~~~~~~~~~~~~~~~~~
313+
314+
Either ``yes`` or ``no`` (default: ``yes``), controlling whether to use native
315+
support for multiple genres per album/track. When enabled, the ``genres`` field
316+
stores genres as a list and writes them to files as multiple individual genre
317+
tags (e.g., separate GENRE tags for FLAC/MP3). The single ``genre`` field is
318+
maintained as a joined string for backward compatibility. When disabled, only
319+
the first genre is used (preserving the old behavior).
320+
321+
.. _genre_separator:
322+
323+
genre_separator
324+
~~~~~~~~~~~~~~~
325+
326+
Default: ``", "``.
327+
328+
The separator string used when joining multiple genres into the single ``genre``
329+
field. This setting is only used when :ref:`multi_value_genres` is enabled. For
330+
example, with the default separator, a track with genres ``["Rock",
331+
"Alternative", "Indie"]`` will have ``genre`` set to ``"Rock, Alternative,
332+
Indie"``. You can customize this to match your preferred format (e.g., ``"; "``
333+
or ``" / "``).
334+
335+
The default (``", "``) matches the :doc:`lastgenre plugin's
336+
</plugins/lastgenre>` default separator for seamless migration. When
337+
:ref:`multi_value_genres` is enabled, this global separator takes precedence
338+
over the lastgenre plugin's ``separator`` option to ensure consistency across
339+
all genre-related operations.
340+
341+
**Custom separator migration**: If you previously used a custom (non-default)
342+
``separator`` in the lastgenre plugin, set ``genre_separator`` to match your
343+
custom value. You can check your existing format by running ``beet ls -f
344+
'$genre' | head -20``. Alternatively, set ``multi_value_genres: no`` to preserve
345+
the old behavior entirely.
346+
309347
.. _per_disc_numbering:
310348

311349
per_disc_numbering

0 commit comments

Comments
 (0)