Skip to content

Add native multiple genres support#6168

Closed
dunkla wants to merge 1 commit intobeetbox:masterfrom
dunkla:claude/add-multiple-artists-01AKN5cZkyhLLwf2zh3ue8rT
Closed

Add native multiple genres support#6168
dunkla wants to merge 1 commit intobeetbox:masterfrom
dunkla:claude/add-multiple-artists-01AKN5cZkyhLLwf2zh3ue8rT

Conversation

@dunkla
Copy link
Contributor

@dunkla dunkla commented Nov 16, 2025

Add native support for multiple genres per album/track

Background

This PR addresses a long-standing feature request to add native support for multiple genres, similar to how beets already handles multiple artists. There was a previous attempt in PR #4751 that stalled. This implementation takes a fresh approach while incorporating feedback from that discussion.

Problem

Currently, beets only supports a single genre field (STRING type). Many users work around this by storing multiple genres as a delimited string (e.g., "Rock; Alternative; Indie"), but this is not a native list field and doesn't write properly to files as separate genre tags.

Meanwhile, beets has had native multi-value support for artists, albumartists, and other fields for years, and the underlying MediaFile library has supported multiple genres since 2014 (beetbox/mediafile#527).

Solution

This PR adds a genres field (MULTI_VALUE_DSV type) that stores genres as a proper list and writes them to files as individual genre tags (e.g., separate GENRE tags for FLAC/MP3 files).

Key features:

  1. New genres field: Stores genres as a list using null-delimited string format in the database
  2. Backward compatibility: The existing genre field remains and stores a joined string (e.g., "Rock; Alternative; Indie") for users who rely on it
  3. Bidirectional sync: The genregenres fields are kept in sync automatically via correct_list_fields()
  4. Optional behavior: A new multi_value_genres config option (default: yes) controls whether to use the new multi-value behavior or fall back to the old single-genre behavior
  5. Plugin updates: Updated MusicBrainz, Beatport, and LastGenre plugins to populate the genres list
  6. Comprehensive tests: Added test coverage for genre field synchronization

Implementation Details

Database schema (beets/library/models.py):

  • Added genres: types.MULTI_VALUE_DSV to both Album and Item models
  • Added to item_keys for proper synchronization

Synchronization logic (beets/autotag/__init__.py):

  • Added sync_genre_fields() function in correct_list_fields()
  • When multi_value_genres=true: genres list is authoritative, genre is the joined string
  • When multi_value_genres=false: Falls back to old behavior (only first genre)
  • Handles whitespace cleanup when splitting delimited strings

Plugin support:

  • MusicBrainz (beetsplug/musicbrainz.py): Populates genres list from tag data
  • Beatport (beetsplug/beatport.py): Extracts multiple genres and subgenres
  • LastGenre (beetsplug/lastgenre/__init__.py): Updated to check genres list first, fallback to splitting genre field

Configuration (beets/config_default.yaml):

  • Added multi_value_genres: yes (enabled by default)

Addresses Previous Feedback

This PR incorporates feedback from the previous attempt (#4751):

✅ Changelog entry added
✅ Config option to make behavior optional
✅ LastGenre plugin compatibility maintained
✅ Clean, single squashed commit
✅ Rebased on current master

Testing

Added comprehensive test coverage:

  • 8 new tests in test/test_autotag.py::TestGenreSync covering all sync scenarios
  • Updated existing tests to work with the new field
  • All tests pass locally

Migration Path

No database migration needed. Existing installations will:

  1. Keep their current genre field values intact
  2. Automatically populate genres list on next metadata update
  3. Can disable new behavior by setting multi_value_genres: no in config

Example Usage

# In config.yaml (default behavior)
multi_value_genres: yes

After import/update, items will have:

item.genre   # "Electronic; House; Techno" (joined string)
item.genres  # ["Electronic", "House", "Techno"] (list)

File format support (via MediaFile):

  • MP3 (ID3v2.4): Multiple TCON frames
  • FLAC/Vorbis: Multiple GENRE tags
  • M4A/MP4: Array in ©gen atom
  • WMA/ASF: Multiple WM/Genre tags

This brings genre handling in line with how beets already handles artists, providing a cleaner and more consistent experience for users managing multi-genre music libraries.

And: yes, this is written with heavy support from claude.

@dunkla dunkla requested a review from a team as a code owner November 16, 2025 17:15
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey there - I've reviewed your changes - here's some feedback:

  • Extract the genre separator into a shared constant to avoid hardcoding '; ' across multiple modules and simplify future adjustments.
  • Refactor the Beatport and MusicBrainz plugins to leverage the core split/join logic instead of duplicating it, ensuring consistent behavior.
  • Consider adding a library load hook to backfill existing "genres" fields from current delimited "genre" values so users see the list immediately without reimporting metadata.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Extract the genre separator into a shared constant to avoid hardcoding '; ' across multiple modules and simplify future adjustments.
- Refactor the Beatport and MusicBrainz plugins to leverage the core split/join logic instead of duplicating it, ensuring consistent behavior.
- Consider adding a library load hook to backfill existing "genres" fields from current delimited "genre" values so users see the list immediately without reimporting metadata.

## Individual Comments

### Comment 1
<location> `beetsplug/beatport.py:236-237` </location>
<code_context>
         if "artists" in data:
             self.artists = [(x["id"], str(x["name"])) for x in data["artists"]]
-        if "genres" in data:
+        if "genres" in data and beets.config["multi_value_genres"]:
             self.genres = [str(x["name"]) for x in data["genres"]]

</code_context>

<issue_to_address>
**suggestion (bug_risk):** The conditional assignment of self.genres may lead to missing genre data if multi_value_genres is disabled.

If multi_value_genres is disabled, self.genres remains unset even when genre data is available, which may cause issues elsewhere in the code. To ensure consistency, set self.genres to a single-element or empty list regardless of the config setting.

```suggestion
        if "genres" in data:
            if beets.config["multi_value_genres"]:
                self.genres = [str(x["name"]) for x in data["genres"]]
            else:
                self.genres = [str(data["genres"][0]["name"])] if data["genres"] else []
```
</issue_to_address>

### Comment 2
<location> `beetsplug/beatport.py:318-320` </location>
<code_context>
+        else:
+            genre_list = []
+
+        if beets.config["multi_value_genres"]:
+            # New behavior: populate both genres list and joined string
+            self.genres = genre_list
+            self.genre = "; ".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


</code_context>

<issue_to_address>
**suggestion:** The genre string join uses a hardcoded separator; consider using a configurable separator.

Using a hardcoded '; ' separator may lead to inconsistency with user preferences. Adopting a configurable separator would align with other plugins and improve flexibility.

```suggestion
            # New behavior: populate both genres list and joined string
            self.genres = genre_list
            separator = beets.config["genre_separator"].get(str, "; ")
            self.genre = separator.join(genre_list) if genre_list else None
```
</issue_to_address>

### Comment 3
<location> `beetsplug/musicbrainz.py:744-750` </location>
<code_context>
+        genre_val = getattr(m, "genre")
+        genres_val = getattr(m, "genres")
+
+        if config["multi_value_genres"]:
+            # New behavior: sync all genres
+            # Standard separator used by MusicBrainz and other plugins
</code_context>

<issue_to_address>
**suggestion:** The genre string join uses a hardcoded separator; consider using a configurable separator.

Using a hardcoded '; ' separator may not align with user settings or other plugins. Please use the configurable separator from the user configuration.
</issue_to_address>

### Comment 4
<location> `test/test_autotag.py:523-531` </location>
<code_context>
+        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):
</code_context>

<issue_to_address>
**suggestion (testing):** Suggestion to add test for None values in genre/genres fields.

Add a test where genre or genres is set to None to ensure the sync logic handles these cases without errors.
</issue_to_address>

### Comment 5
<location> `test/test_autotag.py:503-491` </location>
<code_context>
+        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):
</code_context>

<issue_to_address>
**suggestion (testing):** Suggestion to add test for disabled config with empty genres list.

Please add a test for when multi_value_genres is False and genres is an empty list, to verify correct handling of genre in this scenario.
</issue_to_address>

### Comment 6
<location> `test/test_library.py:691` </location>
<code_context>
         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")
</code_context>

<issue_to_address>
**question (testing):** Question about test coverage for new genres field in library tests.

Consider adding tests that specifically cover the new genres list field in library operations, such as queries or template rendering.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

self._assert_dest(b"/base/not_played")

def test_first(self):
self.i.genres = "Pop; Rock; Classical Crossover"
Copy link
Contributor

Choose a reason for hiding this comment

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

question (testing): Question about test coverage for new genres field in library tests.

Consider adding tests that specifically cover the new genres list field in library operations, such as queries or template rendering.

@codecov
Copy link

codecov bot commented Nov 16, 2025

Codecov Report

❌ Patch coverage is 63.26531% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.42%. Comparing base (07445fd) to head (a72ed8b).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
beetsplug/lastgenre/__init__.py 30.00% 3 Missing and 4 partials ⚠️
beetsplug/beatport.py 58.33% 3 Missing and 2 partials ⚠️
beets/autotag/__init__.py 80.00% 2 Missing and 2 partials ⚠️
beetsplug/musicbrainz.py 66.66% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #6168      +/-   ##
==========================================
- Coverage   67.44%   67.42%   -0.02%     
==========================================
  Files         136      136              
  Lines       18526    18571      +45     
  Branches     3129     3143      +14     
==========================================
+ Hits        12494    12521      +27     
- Misses       5369     5378       +9     
- Partials      663      672       +9     
Files with missing lines Coverage Δ
beets/autotag/hooks.py 100.00% <100.00%> (ø)
beets/library/models.py 87.17% <ø> (ø)
beetsplug/musicbrainz.py 78.86% <66.66%> (-0.25%) ⬇️
beets/autotag/__init__.py 86.56% <80.00%> (-1.16%) ⬇️
beetsplug/beatport.py 43.82% <58.33%> (+0.02%) ⬆️
beetsplug/lastgenre/__init__.py 70.25% <30.00%> (-1.50%) ⬇️
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@dunkla dunkla force-pushed the claude/add-multiple-artists-01AKN5cZkyhLLwf2zh3ue8rT branch from 7d3f6b8 to f36ea7f Compare November 16, 2025 17:25
@dunkla dunkla marked this pull request as draft November 16, 2025 18:03
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)
@dunkla dunkla force-pushed the claude/add-multiple-artists-01AKN5cZkyhLLwf2zh3ue8rT branch from 30b978a to 11f38e8 Compare November 16, 2025 18:13
@dunkla dunkla closed this Nov 16, 2025
@dunkla dunkla deleted the claude/add-multiple-artists-01AKN5cZkyhLLwf2zh3ue8rT branch November 16, 2025 18:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants