Skip to content

Add multiple genres#6367

Open
snejus wants to merge 30 commits intomasterfrom
add-multiple-genres
Open

Add multiple genres#6367
snejus wants to merge 30 commits intomasterfrom
add-multiple-genres

Conversation

@snejus
Copy link
Member

@snejus snejus commented Feb 15, 2026

Add support for a multi-valued genres field

  • Update metadata source plugins to populates genres instead of genre: musicbrainz, beatport, discogs.
  • Remove now redundant separator configuration from lastgenre.
  • Update the %first template function to split by \␀ by default.

Context

We previously had multiple issues with maintaining both singular and plural fields:

  1. Since both fields write and read the same field in music files, the values in both
    fields must be carefully synchronised, otherwise we see these fields being repeatedly
    retagged / rewritten using commands such as beet write. See related issues
  2. Fixes to sync logic required users manually retagging their libraries, while music
    imported as-is could not be fixed. See Stop perpetually writing mb_artistid, mb_albumartistid and albumtypes fields #5540, for example.

Therefore, this PR replaces a singular genre field by plural genres for good:

  1. We migrate genre -> genres immediately on the first beets invocation
  2. genre field is removed and genres is added
  3. The old genre column in the database is left in place - these values will be ignored
    by beets.
    • If someone migrates and later decides to switch back to using an older version of
      beets, their genre values are still in place.

Migration

  • This PR creates a new DB table migrations(name TEXT, table TEXT)

    • We add an entry when a migration has been fully performed on a specific table
    • Thus we only perform the migration if we don't have an entry for that table
    • Entry is only added when the migration has been performed fully: if someone hits
      CTRL-C during the migration, the migration will continue on the next beets invocation,
      see:
      def migrate_table(self, table: str, *args, **kwargs) -> None:
          """Migrate a specific table."""
          if not self.db.migration_exists(self.name, table):
              self._migrate_data(table, *args, **kwargs)
              self.db.record_migration(self.name, table)
  • Implemented using SQL due to:

    1. Significant speed difference: migrating my 9000 tracks / 2000 albums library:
      • Using our Python implementation: over 11 minutes
      • Using SQL: 2 seconds
    2. Beets seeing only genres field: genre field is only accessible by querying the
      database directly.

Supersedes: #6169

@snejus snejus requested a review from a team as a code owner February 15, 2026 13:52
@codecov
Copy link

codecov bot commented Feb 15, 2026

Codecov Report

❌ Patch coverage is 95.18072% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.33%. Comparing base (dcef1f4) to head (cc5c589).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
beetsplug/lastgenre/__init__.py 84.00% 3 Missing and 1 partial ⚠️
beets/library/migrations.py 96.36% 1 Missing and 1 partial ⚠️
beets/autotag/hooks.py 90.90% 0 Missing and 1 partial ⚠️
beets/dbcore/db.py 98.43% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #6367      +/-   ##
==========================================
+ Coverage   69.18%   69.33%   +0.15%     
==========================================
  Files         140      141       +1     
  Lines       18686    18782      +96     
  Branches     3053     3059       +6     
==========================================
+ Hits        12927    13023      +96     
  Misses       5114     5114              
  Partials      645      645              
Files with missing lines Coverage Δ
beets/dbcore/types.py 96.03% <100.00%> (+0.01%) ⬆️
beets/library/library.py 93.65% <100.00%> (+0.20%) ⬆️
beets/library/models.py 87.10% <100.00%> (ø)
beetsplug/aura.py 57.35% <ø> (ø)
beetsplug/beatport.py 42.97% <100.00%> (-0.83%) ⬇️
beetsplug/bpd/__init__.py 24.43% <ø> (ø)
beetsplug/discogs/__init__.py 64.30% <100.00%> (-0.57%) ⬇️
beets/autotag/hooks.py 99.21% <90.90%> (-0.79%) ⬇️
beets/dbcore/db.py 94.41% <98.43%> (+0.26%) ⬆️
beets/library/migrations.py 96.36% <96.36%> (ø)
... and 1 more
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@snejus snejus force-pushed the add-multiple-genres branch 6 times, most recently from 6f18c92 to 8c0820b Compare February 16, 2026 21:49
Copy link
Member

@JOJ0 JOJ0 left a comment

Choose a reason for hiding this comment

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

supported separators in docs correct?

I don't have much to say on the sql implementation except that it works when switching back and forth between this branch and current master and it's super-fast 🤩 👍

if "genres" in data:
self.genres = [str(x["name"]) for x in data["genres"]]
genre_list = [str(x["name"]) for x in data["genres"]]
self.genres = unique_list(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.

👍

Copy link
Member

@JOJ0 JOJ0 left a comment

Choose a reason for hiding this comment

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

JOJ0 added a commit to JOJ0/beets that referenced this pull request Feb 19, 2026
@snejus snejus force-pushed the add-multiple-genres branch from 8c0820b to fbf7085 Compare February 21, 2026 10:54
@snejus
Copy link
Member Author

snejus commented Feb 22, 2026

@JOJ0 just waiting for #6387 to be reviewed and merged which will fix the remaining test failures.

Copilot AI review requested due to automatic review settings February 22, 2026 12:16
@snejus snejus force-pushed the add-multiple-genres branch from fbf7085 to a8101c1 Compare February 22, 2026 12:16
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request implements native multi-value genre support in beets by replacing the singular genre field with a plural genres field across the codebase. This addresses long-standing issues with maintaining synchronization between singular and plural fields that caused repeated retagging operations.

Changes:

  • Introduces database migration infrastructure to support automatic data migrations across schema changes
  • Migrates existing genre string values to genres multi-value field, splitting on common separators (, ; /)
  • Updates metadata source plugins (MusicBrainz, Beatport, Discogs, LastGenre) to populate genres as a list rather than genre as a string
  • Removes the separator configuration option from LastGenre plugin since genres are now stored as lists
  • Updates all tests to use genres field instead of genre

Reviewed changes

Copilot reviewed 38 out of 40 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
beets/dbcore/db.py Adds Migration base class and infrastructure for tracking migration state, adds mutate_many for batch operations, adds db_tables cached property for schema introspection
beets/dbcore/types.py Exports MULTI_VALUE_DELIMITER constant for reuse in migrations
beets/library/library.py Registers MultiGenreFieldMigration to run on library initialization
beets/library/migrations.py Implements genre→genres migration logic with automatic separator detection
beets/library/models.py Changes genre field from STRING to genres field with MULTI_VALUE_DSV type in both Item and Album models
beets/autotag/hooks.py Adds deprecation warning for genre parameter in AlbumInfo constructor with automatic conversion to genres list
beetsplug/musicbrainz.py Updates to populate genres list instead of genre string
beetsplug/lastgenre/init.py Refactors to work with genres as lists, removes separator config option, updates type hints
beetsplug/discogs/init.py Updates to populate genres list and simplifies genre/style handling
beetsplug/beatport.py Updates to populate genres list for both releases and tracks
beetsplug/bpd/init.py Maps MPD "Genre" tag type to "genres" field
beetsplug/aura.py Adds mapping for both "genre" and "genres" to "genres" field for compatibility
test/* Updates all tests to use genres field instead of genre, adds migration tests
docs/* Updates documentation to reflect genres field usage and documents migration behavior
setup.cfg Adds follow_untyped_imports = true to mypy configuration

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@snejus
Copy link
Member Author

snejus commented Feb 22, 2026

@JOJ0 given that Copilot also raised a question regarding separators, I've now documented the precedence and the fact that genre is split by the first found separator only.

@snejus snejus force-pushed the add-multiple-genres branch 3 times, most recently from 151468e to 241d335 Compare February 22, 2026 14:50
@snejus snejus changed the base branch from master to fix-handling-multi-valued-fields February 22, 2026 14:50
@snejus snejus force-pushed the add-multiple-genres branch 2 times, most recently from fbe848a to 80d08bc Compare February 22, 2026 14:54
@snejus snejus force-pushed the fix-handling-multi-valued-fields branch from 03114e1 to 54a46bd Compare February 22, 2026 15:57
@snejus snejus force-pushed the add-multiple-genres branch from 80d08bc to ce5214a Compare February 22, 2026 15:58
dunkla and others added 26 commits February 23, 2026 05:11
Co-authored-by: Šarūnas Nejus <snejus@protonmail.com>
Co-authored-by: Šarūnas Nejus <snejus@protonmail.com>
Co-authored-by: Šarūnas Nejus <snejus@protonmail.com>
- Add Library._make_table() override to automatically migrate genres when database schema is updated
- Migration splits comma/semicolon/slash-separated genre strings into genres list
- Writes changes to both database and media files with progress reporting
- Remove lazy migration from correct_list_fields() - now handled at database level
- Remove migration-specific tests (migration is now automatic, not lazy)
- Update changelog to reflect automatic migration behavior

Related PR review comment changes:
- Replace _is_valid with _filter_valid method in lastgenre plugin
- Use unique_list and remove genre field from Beatport plugin
- Simplify LastGenre tests - remove separator logic
- Document separator deprecation in lastgenre plugin
- Add deprecation warning for genre parameter in Info.__init__()
Remove intermediate variable and assign directly to info.genres.
Addresses PR review comment.
Migration now happens automatically when the database schema is
updated (in Library._make_table()), so the manual 'beet migrate'
command is no longer needed.

Addresses PR review comment.
* Move genre-to-genres migration into a dedicated Migration class and
  wire it into Library._migrations for items and albums.
* Add batched SQL updates via mutate_many and share the multi-value
  delimiter as a constant.
* Cover migration behavior with new tests.

I initially attempted to migrate using our model infrastructure
/ Model.store(), see the comparison below:

Durations migrating my library of ~9000 items and ~2300 albums:
1. Using our Python logic: 11 minutes
2. Using SQL directly: 4 seconds

That's why I've gone ahead with option 2.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 38 out of 40 changed files in this pull request and generated 1 comment.

Comment on lines 68 to +79
def test_store_only_writes_dirty_fields(self):
original_genre = self.i.genre
self.i._values_fixed["genre"] = "beatboxing" # change w/o dirtying
original_genres = self.i.genres
self.i._values_fixed["genres"] = ["beatboxing"] # change w/o dirtying
self.i.store()
new_genre = (
self.lib._connection()
.execute("select genre from items where title = ?", (self.i.title,))
.fetchone()["genre"]
.execute(
"select genres from items where title = ?", (self.i.title,)
)
.fetchone()["genres"]
)
assert new_genre == original_genre
assert [new_genre] == original_genres
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

grug confused by test logic! new_genre is raw database string value (with delimiters, like "beatboxing"), but test compare [new_genre] (string wrapped in list) to original_genres (which already list of strings like ["the genre"]).

this comparison wrong! should either:

  1. Compare new_genre directly to delimited string like MULTI_VALUE_DELIMITER.join(original_genres), or
  2. Parse new_genre first, then compare parsed list to original_genres

currently test checking if ["beatboxing"] equals ["the genre"] which will pass (both different) but for wrong reason!

Copilot uses AI. Check for mistakes.
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.

5 participants