Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions beets/autotag/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment on lines +175 to +215
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be simplified to:

Suggested change
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()
ensure_first_value("genre", "genres")
ensure_first_value("albumtype", "albumtypes")

I think we should migrate to multi-value genres for good and not provide users with an option to not to. Ultimately, this is just about how we store them in our database, so it's best to have a single way of doing it. Thus, there's no need for multi_value_genres or genre_separator options.


if hasattr(m, "mb_artistids"):
ensure_first_value("mb_artistid", "mb_artistids")
Expand Down
2 changes: 2 additions & 0 deletions beets/autotag/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions beets/config_default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions beets/library/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -297,6 +298,7 @@ def _types(cls) -> dict[str, types.Type]:
"albumartists_credit",
"album",
"genre",
"genres",
"style",
"discogs_albumid",
"discogs_artistid",
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 22 additions & 4 deletions beetsplug/beatport.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,12 @@ 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"]]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given my comment above, I expect beatport plugin to simply make sure that it always provides genres 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:
Expand Down Expand Up @@ -306,11 +311,22 @@ 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 = []

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):
Expand Down Expand Up @@ -484,6 +500,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,
Expand All @@ -509,6 +526,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):
Expand Down
38 changes: 36 additions & 2 deletions beetsplug/lastgenre/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,12 +329,35 @@ def _format_and_stringify(self, tags: list[str]) -> str:
else:
formatted = tags

return self.config["separator"].as_str().join(formatted)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The adjustments in lastgenre I'd expect:

  1. Remove/deprecate separator configuration
  2. Write genres field with a list of genres instead of joining them into genre

# 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:
Expand Down Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions beetsplug/musicbrainz.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be just

            info.genres = [
                genre
                for genre, _count in sorted(genres.items(), key=lambda g: -g[1])
            ]

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()
Expand Down
18 changes: 18 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions docs/reference/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading