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)
Codecov Report
:x: Patch coverage is 54.87805% with 37 lines in your changes missing coverage. Please review.
:white_check_mark: Project coverage is 67.86%. Comparing base (2bd77b9) to head (c3f8eac).
:white_check_mark: All tests successful. No failed tests found.
Additional details and impacted files
@@ Coverage Diff @@
## master #6169 +/- ##
==========================================
- Coverage 67.93% 67.86% -0.07%
==========================================
Files 137 138 +1
Lines 18677 18733 +56
Branches 3155 3167 +12
==========================================
+ Hits 12688 12713 +25
- Misses 5324 5355 +31
Partials 665 665
| Files with missing lines | Coverage Δ | |
|---|---|---|
| beets/autotag/hooks.py | 100.00% <100.00%> (ø) |
|
| beets/library/models.py | 87.17% <ø> (ø) |
|
| beets/ui/commands/__init__.py | 100.00% <100.00%> (ø) |
|
| beetsplug/musicbrainz.py | 79.22% <100.00%> (+0.05%) |
:arrow_up: |
| beets/autotag/__init__.py | 88.09% <92.30%> (+0.48%) |
:arrow_up: |
| beetsplug/beatport.py | 44.08% <83.33%> (+0.27%) |
:arrow_up: |
| beetsplug/lastgenre/__init__.py | 72.18% <78.94%> (+0.43%) |
:arrow_up: |
| beets/ui/commands/migrate.py | 22.50% <22.50%> (ø) |
:rocket: New features to boost your workflow:
- :package: JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
Hi @dunkla many many thanks for this contribution! It lately crosses my mind daily that I should probably finally start working on multi-genre support before continuing with my lastgenre masterplan ;-), so thanks again for your motivation to drive this forward.
When I skimmed through it yesterday my first thoughts were that no user and no plugin should have to worry about population both genre and genres, so I'd like to second @snejus here: Writing a genres list should be the only thing that any Beets plugin and user should have to do. Any fallback/opt-in/opt-out scenarios are not necessary and complicate things.
Also @snejus do you think this PR should wait for #6165 to be finished or at least should be based on it (to let @dunkla continue workin on it).
Also thanks for offering to implement the mediafile end @snejus! I think we should finalize the current cleanup first though. Then implement genres first thing! https://github.com/beetbox/mediafile/pull/86
As soon as decisions have been made i'm more than happy to rebase and restructre the approach as wished.
Good news, guys! Apparently, mediafile already supports multivalued genres field! @dunkla you can go ahead and implement the requested changes whenever you have time.
Additionally, I think, we should think of the best way to migrate joined genres from the existing genre field to the new field at the point of this field is created. Unlike in #5540, we have an opportunity to do this properly this time, where we don't need to depend on mbsync / reimports!
Summary of Changes
Addressed PR Comments
1. Simplify the architecture (snejus's main comment)
- Removed
multi_value_genresconfig option from beets/config_default.yaml - Removed
genre_separatorconfig option from beets/config_default.yaml - Replaced complex
sync_genre_fields()with simpleensure_first_value("genre", "genres")call in beets/autotag/init.py:204 - Simplified all plugins to always write genres as lists (no conditional logic based on config)
2. Update Beatport plugin (snejus's comment)
- Simplified beetsplug/beatport.py:236-239 to always populate
genreslist - Removed all conditional
multi_value_genresconfig checks from BeatportRelease and BeatportTrack
3. Update MusicBrainz plugin (snejus's comment)
- Simplified beetsplug/musicbrainz.py:739-743 to write directly to
info.genresas list - Removed config-based conditional logic
4. Update LastGenre plugin (snejus's comment)
- Major refactor of beetsplug/lastgenre/init.py:
- Changed
_get_genre()to return list instead of string - Renamed
_format_and_stringify()to_format_genres()returning list - Removed all separator-related configuration logic
- Simplified
_get_existing_genres()to only read from genres field - Updated
_fetch_and_log_genre()to write directly toobj.genres
- Changed
Addressed Issues
Migration concerns (referenced in PR discussion, relates to #5540)
- Added automatic lazy migration in beets/autotag/init.py:167-186
- Detects comma (", "), semicolon ("; "), and slash (" / ") separated genre strings
- Automatically splits into
genreslist on first item access - No reimport or
mbsyncneeded for existing libraries
- Added explicit
beet migrate genrescommand for batch processing in beets/ui/commands/migrate.py- Supports
--pretendflag to preview changes - Allows users to migrate entire library at once if preferred
- Supports
- Migration strategy avoids endless rewrite loops from #5540:
- Proper field synchronization using
ensure_first_value() - Clears old genre field after splitting to prevent duplication
- No bidirectional sync complexity
- Proper field synchronization using
Other Changes
Tests
- Rewrote all genre sync tests in test/test_autotag.py:480-590
- Added 5 new migration test cases covering different separator types
- Updated LastGenre tests in test/plugins/test_lastgenre.py to expect lists instead of strings
- Updated Beatport tests in test/plugins/test_beatport.py to check
.genresattribute - Fixed library tests in test/test_library.py to work with new field sync
- All 44 genre-related tests passing
Documentation
- Updated docs/changelog.rst:15-29 with simplified feature description
- Added comprehensive migration documentation mentioning both automatic and manual options
- Removed documentation for
multi_value_genresandgenre_separatorconfig options from docs/reference/config.rst
Code Quality
- All linter checks passing (ruff)
- All type checks passing
Implementation Philosophy
The simplified implementation aligns with the maintainer's vision:
- Always use multi-value
genresinternally - Automatic backward-compatible sync to
genrefield viaensure_first_value() - No configuration complexity
- Clean migration path for existing users
- Consistent behavior across all metadata sources
all again with immense help of claude code
Summary of Changes
Addressed PR Comments
1. Simplify the architecture (snejus's main comment)
* Removed `multi_value_genres` config option from beets/config_default.yaml * Removed `genre_separator` config option from beets/config_default.yaml * Replaced complex `sync_genre_fields()` with simple `ensure_first_value("genre", "genres")` call in beets/autotag/**init**.py:204 * Simplified all plugins to always write genres as lists (no conditional logic based on config)2. Update Beatport plugin (snejus's comment)
* Simplified beetsplug/beatport.py:236-239 to always populate `genres` list * Removed all conditional `multi_value_genres` config checks from BeatportRelease and BeatportTrack3. Update MusicBrainz plugin (snejus's comment)
* Simplified beetsplug/musicbrainz.py:739-743 to write directly to `info.genres` as list * Removed config-based conditional logic4. Update LastGenre plugin (snejus's comment)
* Major refactor of beetsplug/lastgenre/**init**.py: * Changed `_get_genre()` to return list instead of string * Renamed `_format_and_stringify()` to `_format_genres()` returning list * Removed all separator-related configuration logic * Simplified `_get_existing_genres()` to only read from genres field * Updated `_fetch_and_log_genre()` to write directly to `obj.genres`Addressed Issues
Migration concerns (referenced in PR discussion, relates to #5540)
* Added automatic lazy migration in beets/autotag/**init**.py:167-186 * Detects comma (", "), semicolon ("; "), and slash (" / ") separated genre strings * Automatically splits into `genres` list on first item access * No reimport or `mbsync` needed for existing libraries * Added explicit `beet migrate genres` command for batch processing in beets/ui/commands/migrate.py * Supports `--pretend` flag to preview changes * Allows users to migrate entire library at once if preferred * Migration strategy avoids endless rewrite loops from [Stop perpetually writing `mb_artistid`, `mb_albumartistid` and `albumtypes` fields #5540](https://github.com/beetbox/beets/pull/5540): * Proper field synchronization using `ensure_first_value()` * Clears old genre field after splitting to prevent duplication * No bidirectional sync complexityOther Changes
Tests
* Rewrote all genre sync tests in test/test_autotag.py:480-590 * Added 5 new migration test cases covering different separator types * Updated LastGenre tests in test/plugins/test_lastgenre.py to expect lists instead of strings * Updated Beatport tests in test/plugins/test_beatport.py to check `.genres` attribute * Fixed library tests in test/test_library.py to work with new field sync * All 44 genre-related tests passingDocumentation
* Updated docs/changelog.rst:15-29 with simplified feature description * Added comprehensive migration documentation mentioning both automatic and manual options * Removed documentation for `multi_value_genres` and `genre_separator` config options from docs/reference/config.rstCode Quality
* All linter checks passing (ruff) * All type checks passingImplementation Philosophy
The simplified implementation aligns with the maintainer's vision:
* Always use multi-value `genres` internally * Automatic backward-compatible sync to `genre` field via `ensure_first_value()` * No configuration complexity * Clean migration path for existing users * Consistent behavior across all metadata sourcesall again with immense help of claude code
I love the amount of detail here @dunkla. I will review this shortly!