beets icon indicating copy to clipboard operation
beets copied to clipboard

Add native support for multiple genres per album/track

Open dunkla opened this issue 1 month ago • 6 comments

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 avatar Nov 16 '25 18:11 dunkla

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.

Files with missing lines Patch % Lines
beets/ui/commands/migrate.py 22.50% 31 Missing :warning:
beetsplug/lastgenre/__init__.py 78.94% 3 Missing and 1 partial :warning:
beets/autotag/__init__.py 92.30% 0 Missing and 1 partial :warning:
beetsplug/beatport.py 83.33% 1 Missing :warning:
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.

codecov[bot] avatar Nov 16 '25 19:11 codecov[bot]

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).

JOJ0 avatar Nov 17 '25 06:11 JOJ0

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

JOJ0 avatar Nov 17 '25 06:11 JOJ0

As soon as decisions have been made i'm more than happy to rebase and restructre the approach as wished.

dunkla avatar Nov 21 '25 16:11 dunkla

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!

snejus avatar Dec 05 '25 08:12 snejus

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 BeatportTrack

3. Update MusicBrainz plugin (snejus's comment)

  • Simplified beetsplug/musicbrainz.py:739-743 to write directly to info.genres as 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 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 #5540:
    • Proper field synchronization using ensure_first_value()
    • Clears old genre field after splitting to prevent duplication
    • No bidirectional sync complexity

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 .genres attribute
  • 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_genres and genre_separator config 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 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 sources

all again with immense help of claude code

dunkla avatar Dec 06 '25 23:12 dunkla

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 BeatportTrack

3. Update MusicBrainz plugin (snejus's comment)

* Simplified beetsplug/musicbrainz.py:739-743 to write directly to `info.genres` as 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 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 complexity

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 `.genres` attribute

* 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_genres` and `genre_separator` config 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 `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 sources

all again with immense help of claude code

I love the amount of detail here @dunkla. I will review this shortly!

snejus avatar Dec 17 '25 11:12 snejus