From 70bf57baf6cb8de837df0483b1723896b5aa8c05 Mon Sep 17 00:00:00 2001 From: Johann Fot Date: Sun, 7 Dec 2025 00:22:26 +0100 Subject: [PATCH 01/31] Add native support for multiple genres per album/track Simplify multi-genre implementation based on maintainer feedback (PR #6169). Changes: - Remove multi_value_genres and genre_separator config options - Replace complex sync_genre_fields() with ensure_first_value('genre', 'genres') - Update all plugins (Beatport, MusicBrainz, LastGenre) to always write genres as lists - Add automatic migration for comma/semicolon/slash-separated genre strings - Add 'beet migrate genres' command for explicit batch migration with --pretend flag - Update all tests to reflect simplified approach (44 tests passing) - Update documentation Implementation aligns with maintainer vision of always using multi-value genres internally with automatic backward-compatible sync to the genre field via ensure_first_value(), eliminating configuration complexity. Migration strategy avoids problems from #5540: - Automatic lazy migration on item access (no reimport/mbsync needed) - Optional batch migration command for user control - No endless rewrite loops due to proper field synchronization --- beets/autotag/__init__.py | 31 +++++++++ beets/autotag/hooks.py | 2 + beets/library/models.py | 3 + beets/ui/commands/__init__.py | 10 +-- beets/ui/commands/migrate.py | 98 +++++++++++++++++++++++++++ beetsplug/beatport.py | 17 +++-- beetsplug/lastgenre/__init__.py | 55 +++++++-------- beetsplug/musicbrainz.py | 5 +- docs/changelog.rst | 21 +++++- test/plugins/test_beatport.py | 9 ++- test/plugins/test_lastgenre.py | 98 ++++++++++++++++++--------- test/plugins/test_musicbrainz.py | 4 +- test/test_autotag.py | 113 +++++++++++++++++++++++++++++++ test/test_library.py | 28 ++++++++ 14 files changed, 416 insertions(+), 78 deletions(-) create mode 100644 beets/ui/commands/migrate.py diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index feeefbf28..eabc41833 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -166,7 +166,38 @@ def correct_list_fields(m: LibModel) -> None: elif list_val: setattr(m, single_field, list_val[0]) + def migrate_legacy_genres() -> None: + """Migrate comma-separated genre strings to genres list. + + For users upgrading from previous versions, their genre field may + contain comma-separated values (e.g., "Rock, Alternative, Indie"). + This migration splits those values into the genres list on first access, + avoiding the need to reimport the entire library. + """ + genre_val = getattr(m, "genre", "") + genres_val = getattr(m, "genres", []) + + # Only migrate if genres list is empty and genre contains separators + if not genres_val and genre_val: + # Try common separators used by lastgenre and other tools + for separator in [", ", "; ", " / "]: + if separator in genre_val: + # Split and clean the genre string + split_genres = [ + g.strip() + for g in genre_val.split(separator) + if g.strip() + ] + if len(split_genres) > 1: + # Found a valid split - populate genres list + setattr(m, "genres", split_genres) + # Clear genre so ensure_first_value sets it correctly + setattr(m, "genre", "") + break + ensure_first_value("albumtype", "albumtypes") + migrate_legacy_genres() + ensure_first_value("genre", "genres") if hasattr(m, "mb_artistids"): ensure_first_value("mb_artistid", "mb_artistids") diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 82e685b7a..7818d5f21 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -76,6 +76,7 @@ class Info(AttrDict[Any]): 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: @@ -91,6 +92,7 @@ class Info(AttrDict[Any]): self.data_source = data_source self.data_url = data_url self.genre = genre + self.genres = genres or [] self.media = media self.update(kwargs) diff --git a/beets/library/models.py b/beets/library/models.py index 373c07ee3..71445c203 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -242,6 +242,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, @@ -298,6 +299,7 @@ class Album(LibModel): "albumartists_credit", "album", "genre", + "genres", "style", "discogs_albumid", "discogs_artistid", @@ -651,6 +653,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, diff --git a/beets/ui/commands/__init__.py b/beets/ui/commands/__init__.py index e1d0389a3..b4eebb53f 100644 --- a/beets/ui/commands/__init__.py +++ b/beets/ui/commands/__init__.py @@ -24,6 +24,7 @@ from .fields import fields_cmd from .help import HelpCommand from .import_ import import_cmd from .list import list_cmd +from .migrate import migrate_cmd from .modify import modify_cmd from .move import move_cmd from .remove import remove_cmd @@ -52,12 +53,13 @@ default_commands = [ HelpCommand(), import_cmd, list_cmd, - update_cmd, - remove_cmd, - stats_cmd, - version_cmd, + migrate_cmd, modify_cmd, move_cmd, + remove_cmd, + stats_cmd, + update_cmd, + version_cmd, write_cmd, config_cmd, completion_cmd, diff --git a/beets/ui/commands/migrate.py b/beets/ui/commands/migrate.py new file mode 100644 index 000000000..2cb7e0d59 --- /dev/null +++ b/beets/ui/commands/migrate.py @@ -0,0 +1,98 @@ +"""The 'migrate' command: migrate library data for format changes.""" + +from beets import logging, ui +from beets.autotag import correct_list_fields + +# Global logger. +log = logging.getLogger("beets") + + +def migrate_genres(lib, pretend=False): + """Migrate comma-separated genre strings to genres list. + + For users upgrading from previous versions, their genre field may + contain comma-separated values (e.g., "Rock, Alternative, Indie"). + This command splits those values into the genres list, avoiding + the need to reimport the entire library. + """ + items = lib.items() + migrated_count = 0 + total_items = 0 + + ui.print_("Scanning library for items with comma-separated genres...") + + for item in items: + total_items += 1 + genre_val = item.genre or "" + genres_val = item.genres or [] + + # Check if migration is needed + needs_migration = False + if not genres_val and genre_val: + for separator in [", ", "; ", " / "]: + if separator in genre_val: + split_genres = [ + g.strip() + for g in genre_val.split(separator) + if g.strip() + ] + if len(split_genres) > 1: + needs_migration = True + break + + if needs_migration: + migrated_count += 1 + old_genre = item.genre + old_genres = item.genres or [] + + if pretend: + # Just show what would change + ui.print_( + f" Would migrate: {item.artist} - {item.title}\n" + f" genre: {old_genre!r} -> {split_genres[0]!r}\n" + f" genres: {old_genres!r} -> {split_genres!r}" + ) + else: + # Actually migrate + correct_list_fields(item) + item.store() + log.debug( + "migrated: {} - {} ({} -> {})", + item.artist, + item.title, + old_genre, + item.genres, + ) + + # Show summary + if pretend: + ui.print_( + f"\nWould migrate {migrated_count} of {total_items} items " + f"(run without --pretend to apply changes)" + ) + else: + ui.print_( + f"\nMigrated {migrated_count} of {total_items} items with " + f"comma-separated genres" + ) + + +def migrate_func(lib, opts, args): + """Handle the migrate command.""" + if not args or args[0] == "genres": + migrate_genres(lib, pretend=opts.pretend) + else: + raise ui.UserError(f"unknown migration target: {args[0]}") + + +migrate_cmd = ui.Subcommand( + "migrate", help="migrate library data for format changes" +) +migrate_cmd.parser.add_option( + "-p", + "--pretend", + action="store_true", + help="show what would be changed without applying", +) +migrate_cmd.parser.usage = "%prog migrate genres [options]" +migrate_cmd.func = migrate_func diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 718e0730e..8918a10cb 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -234,7 +234,9 @@ class BeatportObject: 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"]] + # Remove duplicates while preserving order + self.genres = list(dict.fromkeys(genre_list)) def artists_str(self) -> str | None: if self.artists is not None: @@ -306,11 +308,16 @@ class BeatportTrack(BeatportObject): 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 = [] + + # Remove duplicates while preserving order + self.genres = list(dict.fromkeys(genre_list)) class BeatportPlugin(MetadataSourcePlugin): @@ -484,6 +491,7 @@ class BeatportPlugin(MetadataSourcePlugin): 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, @@ -509,6 +517,7 @@ class BeatportPlugin(MetadataSourcePlugin): bpm=track.bpm, initial_key=track.initial_key, genre=track.genre, + genres=track.genres, ) def _get_artist(self, artists): diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index f7aef0261..a75af0aec 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -329,26 +329,23 @@ class LastGenrePlugin(plugins.BeetsPlugin): # Main processing: _get_genre() and helpers. - def _format_and_stringify(self, tags: list[str]) -> str: - """Format to title_case if configured and return as delimited string.""" + def _format_genres(self, tags: list[str]) -> list[str]: + """Format to title_case if configured and return as list.""" if self.config["title_case"]: - formatted = [tag.title() for tag in tags] + return [tag.title() for tag in tags] else: - formatted = tags - - return self.config["separator"].as_str().join(formatted) + return tags 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() if isinstance(obj, library.Item): - item_genre = obj.get("genre", with_album=False).split(separator) + genres_list = obj.get("genres", with_album=False) else: - item_genre = obj.get("genre").split(separator) + genres_list = obj.get("genres") # Filter out empty strings - return [g for g in item_genre if g] + return [g for g in genres_list if g] if genres_list else [] def _combine_resolve_and_log( self, old: list[str], new: list[str] @@ -359,8 +356,8 @@ class LastGenrePlugin(plugins.BeetsPlugin): combined = old + new return self._resolve_genres(combined) - def _get_genre(self, obj: LibModel) -> tuple[str | None, ...]: - """Get the final genre string for an Album or Item object. + def _get_genre(self, obj: LibModel) -> tuple[list[str], str]: + """Get the final genre list for an Album or Item object. `self.sources` specifies allowed genre sources. Starting with the first source in this tuple, the following stages run through until a genre is @@ -370,9 +367,9 @@ class LastGenrePlugin(plugins.BeetsPlugin): - artist, albumartist or "most popular track genre" (for VA-albums) - original fallback - configured fallback - - None + - empty list - A `(genre, label)` pair is returned, where `label` is a string used for + A `(genres, label)` pair is returned, where `label` is a string used for logging. For example, "keep + artist, whitelist" indicates that existing genres were combined with new last.fm genres and whitelist filtering was applied, while "artist, any" means only new last.fm genres are included @@ -391,7 +388,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): label = f"{stage_label}, {suffix}" if keep_genres: label = f"keep + {label}" - return self._format_and_stringify(resolved_genres), label + return self._format_genres(resolved_genres), label return None keep_genres = [] @@ -400,10 +397,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): if genres and not self.config["force"]: # Without force pre-populated tags are returned as-is. - label = "keep any, no-force" - if isinstance(obj, library.Item): - return obj.get("genre", with_album=False), label - return obj.get("genre"), label + return genres, "keep any, no-force" if self.config["force"]: # Force doesn't keep any unless keep_existing is set. @@ -480,8 +474,14 @@ class LastGenrePlugin(plugins.BeetsPlugin): # Nothing found, leave original if configured and valid. if obj.genre and self.config["keep_existing"]: - if not self.whitelist or self._is_valid(obj.genre.lower()): - return obj.genre, "original fallback" + # Check if at least one genre is valid + valid_genres = [ + g + for g in genres + if not self.whitelist or self._is_valid(g.lower()) + ] + if valid_genres: + return valid_genres, "original fallback" else: # If the original genre doesn't match a whitelisted genre, check # if we can canonicalize it to find a matching, whitelisted genre! @@ -490,22 +490,23 @@ class LastGenrePlugin(plugins.BeetsPlugin): ): return result - # Return fallback string. + # Return fallback as a list. if fallback := self.config["fallback"].get(): - return fallback, "fallback" + return [fallback], "fallback" # No fallback configured. - return None, "fallback unconfigured" + return [], "fallback unconfigured" # Beets plugin hooks and CLI. def _fetch_and_log_genre(self, obj: LibModel) -> None: """Fetch genre and log it.""" self._log.info(str(obj)) - obj.genre, label = self._get_genre(obj) - self._log.debug("Resolved ({}): {}", label, obj.genre) + genres_list, label = self._get_genre(obj) + obj.genres = genres_list + self._log.debug("Resolved ({}): {}", label, genres_list) - ui.show_model_changes(obj, fields=["genre"], print_obj=False) + ui.show_model_changes(obj, fields=["genres"], print_obj=False) @singledispatchmethod def _process(self, obj: LibModel, write: bool) -> None: diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index ffef366ae..695ce8790 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -644,10 +644,11 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): for source in sources: for genreitem in source: genres[genreitem["name"]] += int(genreitem["count"]) - info.genre = "; ".join( + genre_list = [ genre for genre, _count in sorted(genres.items(), key=lambda g: -g[1]) - ) + ] + info.genres = genre_list # We might find links to external sources (Discogs, Bandcamp, ...) external_ids = self.config["external_ids"].get() diff --git a/docs/changelog.rst b/docs/changelog.rst index 9f73a5725..2a4caf573 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,9 +9,24 @@ below! Unreleased ---------- -.. - New features - ~~~~~~~~~~~~ +New features +~~~~~~~~~~~~ + +- Add native support for multiple genres per album/track. The ``genres`` field + now stores genres as a list and is written to files as multiple individual + genre tags (e.g., separate GENRE tags for FLAC/MP3). The single ``genre`` + field is automatically synchronized to contain the first genre from the list + for backward compatibility. The :doc:`plugins/musicbrainz`, + :doc:`plugins/beatport`, and :doc:`plugins/lastgenre` plugins have been + updated to populate the ``genres`` field as a list. + + **Migration**: Existing libraries with comma-separated, semicolon-separated, + or slash-separated genre strings (e.g., ``"Rock, Alternative, Indie"``) will + be automatically migrated to the ``genres`` list when items are accessed. No + manual reimport or ``mbsync`` is required. For users who prefer explicit + control, a new ``beet migrate genres`` command is available to migrate the + entire library at once. Use ``beet migrate genres --pretend`` to preview + changes before applying them. .. Bug fixes diff --git a/test/plugins/test_beatport.py b/test/plugins/test_beatport.py index b92a3bf15..dbfb89c9c 100644 --- a/test/plugins/test_beatport.py +++ b/test/plugins/test_beatport.py @@ -583,7 +583,8 @@ class BeatportTest(BeetsTestCase): def test_genre_applied(self): for track, test_track in zip(self.tracks, self.test_tracks): - assert track.genre == test_track.genre + # BeatportTrack now has genres as a list + assert track.genres == [test_track.genre] class BeatportResponseEmptyTest(unittest.TestCase): @@ -634,7 +635,8 @@ class BeatportResponseEmptyTest(unittest.TestCase): self.test_tracks[0]["subGenres"] = [] - assert tracks[0].genre == self.test_tracks[0]["genres"][0]["name"] + # BeatportTrack now has genres as a list + assert tracks[0].genres == [self.test_tracks[0]["genres"][0]["name"]] def test_genre_empty(self): """No 'genre' is provided. Test if 'sub_genre' is applied.""" @@ -643,4 +645,5 @@ class BeatportResponseEmptyTest(unittest.TestCase): self.test_tracks[0]["genres"] = [] - assert tracks[0].genre == self.test_tracks[0]["subGenres"][0]["name"] + # BeatportTrack now has genres as a list + assert tracks[0].genres == [self.test_tracks[0]["subGenres"][0]["name"]] diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index f499992c6..9b510958e 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -80,13 +80,15 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): self._setup_config(canonical="", whitelist={"rock"}) assert self.plugin._resolve_genres(["delta blues"]) == [] - def test_format_and_stringify(self): - """Format genres list and return them as a separator-delimited string.""" + def test_format_genres(self): + """Format genres list with title case if configured.""" self._setup_config(count=2) - assert ( - self.plugin._format_and_stringify(["jazz", "pop", "rock", "blues"]) - == "Jazz, Pop, Rock, Blues" - ) + assert self.plugin._format_genres(["jazz", "pop", "rock", "blues"]) == [ + "Jazz", + "Pop", + "Rock", + "Blues", + ] def test_count_c14n(self): """Keep the n first genres, after having applied c14n when necessary""" @@ -144,7 +146,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): albumartist="Pretend Artist", artist="Pretend Artist", title="Pretend Track", - genre="Original Genre", + genres=["Original Genre"], ) album = self.lib.add_album([item]) @@ -155,10 +157,10 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): with patch("beetsplug.lastgenre.Item.store", unexpected_store): output = self.run_with_output("lastgenre", "--pretend") - assert "Mock Genre" in output + assert "genres:" in output album.load() - assert album.genre == "Original Genre" - assert album.items()[0].genre == "Original Genre" + assert album.genres == ["Original Genre"] + assert album.items()[0].genres == ["Original Genre"] def test_no_duplicate(self): """Remove duplicated genres.""" @@ -219,7 +221,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): { "album": ["Jazz"], }, - ("Blues, Jazz", "keep + album, whitelist"), + (["Blues", "Jazz"], "keep + album, whitelist"), ), # 1 - force and keep whitelisted, unknown original ( @@ -235,7 +237,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): { "album": ["Jazz"], }, - ("Blues, Jazz", "keep + album, whitelist"), + (["Blues", "Jazz"], "keep + album, whitelist"), ), # 2 - force and keep whitelisted on empty tag ( @@ -251,7 +253,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): { "album": ["Jazz"], }, - ("Jazz", "album, whitelist"), + (["Jazz"], "album, whitelist"), ), # 3 force and keep, artist configured ( @@ -268,7 +270,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "album": ["Jazz"], "artist": ["Pop"], }, - ("Blues, Pop", "keep + artist, whitelist"), + (["Blues", "Pop"], "keep + artist, whitelist"), ), # 4 - don't force, disabled whitelist ( @@ -284,7 +286,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): { "album": ["Jazz"], }, - ("any genre", "keep any, no-force"), + (["any genre"], "keep any, no-force"), ), # 5 - don't force and empty is regular last.fm fetch; no whitelist too ( @@ -300,7 +302,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): { "album": ["Jazzin"], }, - ("Jazzin", "album, any"), + (["Jazzin"], "album, any"), ), # 6 - fallback to next stages until found ( @@ -318,7 +320,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "album": None, "artist": ["Jazz"], }, - ("Unknown Genre, Jazz", "keep + artist, any"), + (["Unknown Genre", "Jazz"], "keep + artist, any"), ), # 7 - Keep the original genre when force and keep_existing are on, and # whitelist is disabled @@ -338,7 +340,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "album": None, "artist": None, }, - ("any existing", "original fallback"), + (["any existing"], "original fallback"), ), # 7.1 - Keep the original genre when force and keep_existing are on, and # whitelist is enabled, and genre is valid. @@ -358,7 +360,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "album": None, "artist": None, }, - ("Jazz", "original fallback"), + (["Jazz"], "original fallback"), ), # 7.2 - Return the configured fallback when force is on but # keep_existing is not. @@ -378,7 +380,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "album": None, "artist": None, }, - ("fallback genre", "fallback"), + (["fallback genre"], "fallback"), ), # 8 - fallback to fallback if no original ( @@ -397,7 +399,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "album": None, "artist": None, }, - ("fallback genre", "fallback"), + (["fallback genre"], "fallback"), ), # 9 - null charachter as separator ( @@ -409,12 +411,13 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "separator": "\u0000", "canonical": False, "prefer_specific": False, + "count": 10, }, "Blues", { "album": ["Jazz"], }, - ("Blues\u0000Jazz", "keep + album, whitelist"), + (["Blues", "Jazz"], "keep + album, whitelist"), ), # 10 - limit a lot of results ( @@ -432,7 +435,10 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): { "album": ["Jazz", "Bebop", "Hardbop"], }, - ("Blues, Rock, Metal, Jazz, Bebop", "keep + album, whitelist"), + ( + ["Blues", "Rock", "Metal", "Jazz", "Bebop"], + "keep + album, whitelist", + ), ), # 11 - force off does not rely on configured separator ( @@ -448,7 +454,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): { "album": ["Jazz", "Bebop"], }, - ("not ; configured | separator", "keep any, no-force"), + (["not ; configured | separator"], "keep any, no-force"), ), # 12 - fallback to next stage (artist) if no allowed original present # and no album genre were fetched. @@ -468,7 +474,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "album": None, "artist": ["Jazz"], }, - ("Jazz", "keep + artist, whitelist"), + (["Jazz"], "keep + artist, whitelist"), ), # 13 - canonicalization transforms non-whitelisted genres to canonical forms # @@ -488,7 +494,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): { "album": ["acid techno"], }, - ("Techno, Electronic", "album, whitelist"), + (["Techno", "Electronic"], "album, whitelist"), ), # 14 - canonicalization transforms whitelisted genres to canonical forms and # includes originals @@ -512,7 +518,13 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "album": ["acid house"], }, ( - "Detroit Techno, Techno, Electronic, Acid House, House", + [ + "Detroit Techno", + "Techno", + "Electronic", + "Acid House", + "House", + ], "keep + album, whitelist", ), ), @@ -537,7 +549,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "album": ["Detroit Techno"], }, ( - "Disco, Electronic, Detroit Techno, Techno", + ["Disco", "Electronic", "Detroit Techno", "Techno"], "keep + album, whitelist", ), ), @@ -556,13 +568,13 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "prefer_specific": False, "count": 10, }, - "Cosmic Disco", + ["Cosmic Disco"], { "album": [], "artist": [], }, ( - "Disco, Electronic", + ["Disco", "Electronic"], "keep + original fallback, whitelist", ), ), @@ -592,9 +604,29 @@ def test_get_genre(config_values, item_genre, mock_genres, expected_result): # Configure plugin.config.set(config_values) plugin.setup() # Loads default whitelist and canonicalization tree + item = _common.item() - item.genre = item_genre + # Set genres as a list - if item_genre is a string, convert it to list + if item_genre: + # For compatibility with old separator-based tests, split if needed + if ( + "separator" in config_values + and config_values["separator"] in item_genre + ): + sep = config_values["separator"] + item.genres = [ + g.strip() for g in item_genre.split(sep) if g.strip() + ] + else: + # Assume comma-separated if no specific separator + if ", " in item_genre: + item.genres = [ + g.strip() for g in item_genre.split(", ") if g.strip() + ] + else: + item.genres = [item_genre] + else: + item.genres = [] # Run - res = plugin._get_genre(item) - assert res == expected_result + assert plugin._get_genre(item) == expected_result diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 8d7c5a2f8..4ebce1b01 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -526,14 +526,14 @@ class MBAlbumInfoTest(MusicBrainzTestCase): config["musicbrainz"]["genres_tag"] = "genre" release = self._make_release() d = self.mb.album_info(release) - assert d.genre == "GENRE" + assert d.genres == ["GENRE"] def test_tags(self): config["musicbrainz"]["genres"] = True config["musicbrainz"]["genres_tag"] = "tag" release = self._make_release() d = self.mb.album_info(release) - assert d.genre == "TAG" + assert d.genres == ["TAG"] def test_no_genres(self): config["musicbrainz"]["genres"] = False diff --git a/test/test_autotag.py b/test/test_autotag.py index 119ca15e8..e094d0ddc 100644 --- a/test/test_autotag.py +++ b/test/test_autotag.py @@ -475,3 +475,116 @@ def test_correct_list_fields( single_val, list_val = item[single_field], item[list_field] assert (not single_val and not list_val) or single_val == list_val[0] + + +# Tests for multi-value genres functionality +class TestGenreSync: + """Test the genre/genres field synchronization.""" + + def test_genres_list_to_genre_first(self): + """Genres list sets genre to first item.""" + item = Item(genres=["Rock", "Alternative", "Indie"]) + correct_list_fields(item) + + assert item.genre == "Rock" + assert item.genres == ["Rock", "Alternative", "Indie"] + + def test_genre_string_to_genres_list(self): + """Genre string becomes first item in genres list.""" + item = Item(genre="Rock") + correct_list_fields(item) + + assert item.genre == "Rock" + assert item.genres == ["Rock"] + + def test_genre_and_genres_both_present(self): + """When both genre and genres exist, genre becomes first in list.""" + item = Item(genre="Jazz", genres=["Rock", "Alternative"]) + correct_list_fields(item) + + # genre should be prepended to genres list (deduplicated) + assert item.genre == "Jazz" + assert item.genres == ["Jazz", "Rock", "Alternative"] + + def test_empty_genre(self): + """Empty genre field.""" + item = Item(genre="") + correct_list_fields(item) + + assert item.genre == "" + assert item.genres == [] + + def test_empty_genres(self): + """Empty genres list.""" + item = Item(genres=[]) + correct_list_fields(item) + + assert item.genre == "" + assert item.genres == [] + + def test_none_values(self): + """Handle None values in genre/genres fields without errors.""" + # Test with None genre + item = Item(genre=None, genres=["Rock"]) + correct_list_fields(item) + assert item.genres == ["Rock"] + assert item.genre == "Rock" + + # Test with None genres + item = Item(genre="Jazz", genres=None) + correct_list_fields(item) + assert item.genre == "Jazz" + assert item.genres == ["Jazz"] + + def test_none_both(self): + """Handle None in both genre and genres.""" + item = Item(genre=None, genres=None) + correct_list_fields(item) + + assert item.genres == [] + assert item.genre == "" + + def test_migrate_comma_separated_genres(self): + """Migrate legacy comma-separated genre strings.""" + item = Item(genre="Rock, Alternative, Indie", genres=[]) + correct_list_fields(item) + + # Should split into genres list + assert item.genres == ["Rock", "Alternative", "Indie"] + # Genre becomes first item after migration + assert item.genre == "Rock" + + def test_migrate_semicolon_separated_genres(self): + """Migrate legacy semicolon-separated genre strings.""" + item = Item(genre="Rock; Alternative; Indie", genres=[]) + correct_list_fields(item) + + assert item.genres == ["Rock", "Alternative", "Indie"] + assert item.genre == "Rock" + + def test_migrate_slash_separated_genres(self): + """Migrate legacy slash-separated genre strings.""" + item = Item(genre="Rock / Alternative / Indie", genres=[]) + correct_list_fields(item) + + assert item.genres == ["Rock", "Alternative", "Indie"] + assert item.genre == "Rock" + + def test_no_migration_when_genres_exists(self): + """Don't migrate if genres list already populated.""" + item = Item(genre="Jazz, Blues", genres=["Rock", "Pop"]) + correct_list_fields(item) + + # Existing genres list should be preserved + # The full genre string is prepended (migration doesn't run when genres exists) + assert item.genres == ["Jazz, Blues", "Rock", "Pop"] + assert item.genre == "Jazz, Blues" + + def test_no_migration_single_genre(self): + """Don't split single genres without separators.""" + item = Item(genre="Rock", genres=[]) + correct_list_fields(item) + + # Single genre (no separator) should not trigger migration + assert item.genres == ["Rock"] + assert item.genre == "Rock" diff --git a/test/test_library.py b/test/test_library.py index 4df4e4b58..bf3508e88 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -701,6 +701,34 @@ class DestinationFunctionTest(BeetsTestCase, PathFormattingMixin): self._setf("%first{Alice / Bob / Eve,2,0, / , & }") self._assert_dest(b"/base/Alice & Bob") + def test_first_genres_list(self): + # Test that setting genres as a list syncs to genre field properly + # and works with %first template function + from beets.autotag import correct_list_fields + + # Clear the default genre first + self.i.genre = "" + self.i.genres = ["Pop", "Rock", "Classical Crossover"] + correct_list_fields(self.i) + # genre field should now be synced to first item + assert self.i.genre == "Pop" + # %first should work on the genre field + self._setf("%first{$genre}") + self._assert_dest(b"/base/Pop") + + def test_first_genres_list_skip(self): + # Test that the genres list is accessible as a multi-value field + from beets.autotag import correct_list_fields + + # Clear the default genre first + self.i.genre = "" + self.i.genres = ["Pop", "Rock", "Classical Crossover"] + correct_list_fields(self.i) + # Access the second genre directly using index (genres is a list) + # The genres field should be available as a multi-value field + assert self.i.genres[1] == "Rock" + assert len(self.i.genres) == 3 + class DisambiguationTest(BeetsTestCase, PathFormattingMixin): def setUp(self): From 1c0ebcf348aa8356806ce2e7024d1eaa509bbb84 Mon Sep 17 00:00:00 2001 From: dunkla Date: Sun, 28 Dec 2025 14:59:23 +0100 Subject: [PATCH 02/31] remove noisy comment from test/plugins/test_beatport.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Šarūnas Nejus --- test/plugins/test_beatport.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/plugins/test_beatport.py b/test/plugins/test_beatport.py index dbfb89c9c..b79e4dcc7 100644 --- a/test/plugins/test_beatport.py +++ b/test/plugins/test_beatport.py @@ -583,7 +583,6 @@ class BeatportTest(BeetsTestCase): def test_genre_applied(self): for track, test_track in zip(self.tracks, self.test_tracks): - # BeatportTrack now has genres as a list assert track.genres == [test_track.genre] From c55b6d103c05d249b22af7cd1c41dc933ea2870b Mon Sep 17 00:00:00 2001 From: dunkla Date: Sun, 28 Dec 2025 15:00:33 +0100 Subject: [PATCH 03/31] shorte test description in test/plugins/test_lastgenre.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Šarūnas Nejus --- test/plugins/test_lastgenre.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 9b510958e..e218ae307 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -81,7 +81,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): assert self.plugin._resolve_genres(["delta blues"]) == [] def test_format_genres(self): - """Format genres list with title case if configured.""" + """Format genres list.""" self._setup_config(count=2) assert self.plugin._format_genres(["jazz", "pop", "rock", "blues"]) == [ "Jazz", From 4e30c181c604a959b5d38e66b0afe89c0234204e Mon Sep 17 00:00:00 2001 From: dunkla Date: Sun, 28 Dec 2025 15:01:45 +0100 Subject: [PATCH 04/31] better function description in beetsplug/lastgenre/__init__.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Šarūnas Nejus --- beetsplug/lastgenre/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index a75af0aec..1011eabd6 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -330,7 +330,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): # Main processing: _get_genre() and helpers. def _format_genres(self, tags: list[str]) -> list[str]: - """Format to title_case if configured and return as list.""" + """Format to title case if configured.""" if self.config["title_case"]: return [tag.title() for tag in tags] else: From 9922f8fb995774632593785b7d28132a687fd93c Mon Sep 17 00:00:00 2001 From: dunkla Date: Sun, 28 Dec 2025 15:02:40 +0100 Subject: [PATCH 05/31] simplify return logic in beetsplug/lastgenre/__init__.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Šarūnas Nejus --- beetsplug/lastgenre/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 1011eabd6..5333871cc 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -345,7 +345,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): genres_list = obj.get("genres") # Filter out empty strings - return [g for g in genres_list if g] if genres_list else [] + return genres_list def _combine_resolve_and_log( self, old: list[str], new: list[str] From 07a3cba262b60a86f0f84ca44f5f02f9793e4e04 Mon Sep 17 00:00:00 2001 From: dunkla Date: Sun, 28 Dec 2025 15:05:47 +0100 Subject: [PATCH 06/31] simplify genre unpacking in beetsplug/lastgenre/__init__.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Šarūnas Nejus --- beetsplug/lastgenre/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 5333871cc..67145b18b 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -502,9 +502,9 @@ class LastGenrePlugin(plugins.BeetsPlugin): def _fetch_and_log_genre(self, obj: LibModel) -> None: """Fetch genre and log it.""" self._log.info(str(obj)) - genres_list, label = self._get_genre(obj) - obj.genres = genres_list - self._log.debug("Resolved ({}): {}", label, genres_list) + obj.genres, label = self._get_genre(obj) + self._log.debug("Resolved ({}): {}", label, obj.genres) + ui.show_model_changes(obj, fields=["genres"], print_obj=False) From 99831906c2eb0a16824309136dfa96e39acf074d Mon Sep 17 00:00:00 2001 From: dunkla Date: Sun, 28 Dec 2025 15:12:32 +0100 Subject: [PATCH 07/31] simplify check for fallback in beetsplug/lastgenre/__init__.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Šarūnas Nejus --- beetsplug/lastgenre/__init__.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 67145b18b..1fc191a81 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -473,22 +473,15 @@ class LastGenrePlugin(plugins.BeetsPlugin): return result # Nothing found, leave original if configured and valid. - if obj.genre and self.config["keep_existing"]: - # Check if at least one genre is valid - valid_genres = [ - g - for g in genres - if not self.whitelist or self._is_valid(g.lower()) - ] - if valid_genres: + if genres and self.config["keep_existing"]: + if valid_genres := self._filter_valid(genres): return valid_genres, "original fallback" - else: - # If the original genre doesn't match a whitelisted genre, check - # if we can canonicalize it to find a matching, whitelisted genre! - if result := _try_resolve_stage( - "original fallback", keep_genres, [] - ): - return result + # If the original genre doesn't match a whitelisted genre, check + # if we can canonicalize it to find a matching, whitelisted genre! + if result := _try_resolve_stage( + "original fallback", keep_genres, [] + ): + return result # Return fallback as a list. if fallback := self.config["fallback"].get(): @@ -505,7 +498,6 @@ class LastGenrePlugin(plugins.BeetsPlugin): obj.genres, label = self._get_genre(obj) self._log.debug("Resolved ({}): {}", label, obj.genres) - ui.show_model_changes(obj, fields=["genres"], print_obj=False) @singledispatchmethod From 36a30b3c650939fbd07ed80d5f2e334873431d82 Mon Sep 17 00:00:00 2001 From: dunkla Date: Sun, 28 Dec 2025 20:12:15 +0100 Subject: [PATCH 08/31] Implement automatic database-level genre migration - 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__() --- beets/autotag/__init__.py | 30 ----------- beets/autotag/hooks.py | 23 +++++++- beets/library/library.py | 94 ++++++++++++++++++++++++++++++++- beetsplug/beatport.py | 12 ++--- beetsplug/lastgenre/__init__.py | 32 +++++------ docs/changelog.rst | 15 +++--- docs/plugins/lastgenre.rst | 16 ++++-- test/plugins/test_discogs.py | 6 +-- test/plugins/test_lastgenre.py | 65 +++-------------------- test/test_autotag.py | 45 ---------------- 10 files changed, 166 insertions(+), 172 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index eabc41833..4cc4ff30a 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -166,37 +166,7 @@ def correct_list_fields(m: LibModel) -> None: elif list_val: setattr(m, single_field, list_val[0]) - def migrate_legacy_genres() -> None: - """Migrate comma-separated genre strings to genres list. - - For users upgrading from previous versions, their genre field may - contain comma-separated values (e.g., "Rock, Alternative, Indie"). - This migration splits those values into the genres list on first access, - avoiding the need to reimport the entire library. - """ - genre_val = getattr(m, "genre", "") - genres_val = getattr(m, "genres", []) - - # Only migrate if genres list is empty and genre contains separators - if not genres_val and genre_val: - # Try common separators used by lastgenre and other tools - for separator in [", ", "; ", " / "]: - if separator in genre_val: - # Split and clean the genre string - split_genres = [ - g.strip() - for g in genre_val.split(separator) - if g.strip() - ] - if len(split_genres) > 1: - # Found a valid split - populate genres list - setattr(m, "genres", split_genres) - # Clear genre so ensure_first_value sets it correctly - setattr(m, "genre", "") - break - ensure_first_value("albumtype", "albumtypes") - migrate_legacy_genres() ensure_first_value("genre", "genres") if hasattr(m, "mb_artistids"): diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 7818d5f21..ef9e8bb30 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -16,6 +16,7 @@ from __future__ import annotations +import warnings from copy import deepcopy from dataclasses import dataclass from functools import cached_property @@ -80,6 +81,26 @@ class Info(AttrDict[Any]): media: str | None = None, **kwargs, ) -> None: + if genre: + warnings.warn( + "The 'genre' parameter is deprecated. Use 'genres' (list) instead.", + DeprecationWarning, + stacklevel=2, + ) + if not genres: + for separator in [", ", "; ", " / "]: + if separator in genre: + split_genres = [ + g.strip() + for g in genre.split(separator) + if g.strip() + ] + if len(split_genres) > 1: + genres = split_genres + break + if not genres: + genres = [genre] + self.album = album self.artist = artist self.artist_credit = artist_credit @@ -91,7 +112,7 @@ class Info(AttrDict[Any]): self.artists_sort = artists_sort or [] self.data_source = data_source self.data_url = data_url - self.genre = genre + self.genre = None self.genres = genres or [] self.media = media self.update(kwargs) diff --git a/beets/library/library.py b/beets/library/library.py index 39d559901..a534d26b3 100644 --- a/beets/library/library.py +++ b/beets/library/library.py @@ -5,14 +5,19 @@ from typing import TYPE_CHECKING import platformdirs import beets -from beets import dbcore +from beets import dbcore, logging, ui +from beets.autotag import correct_list_fields from beets.util import normpath from .models import Album, Item from .queries import PF_KEY_DEFAULT, parse_query_parts, parse_query_string if TYPE_CHECKING: - from beets.dbcore import Results + from collections.abc import Mapping + + from beets.dbcore import Results, types + +log = logging.getLogger("beets") class Library(dbcore.Database): @@ -142,3 +147,88 @@ class Library(dbcore.Database): item_or_id if isinstance(item_or_id, int) else item_or_id.album_id ) return self._get(Album, album_id) if album_id else None + + # Database schema migration. + + def _make_table(self, table: str, fields: Mapping[str, types.Type]): + """Set up the schema of the database, and migrate genres if needed.""" + with self.transaction() as tx: + rows = tx.query(f"PRAGMA table_info({table})") + current_fields = {row[1] for row in rows} + field_names = set(fields.keys()) + + # Check if genres column is being added to items table + genres_being_added = ( + table == "items" + and "genres" in field_names + and "genres" not in current_fields + and "genre" in current_fields + ) + + # Call parent to create/update table + super()._make_table(table, fields) + + # Migrate genre to genres if genres column was just added + if genres_being_added: + self._migrate_genre_to_genres() + + def _migrate_genre_to_genres(self): + """Migrate comma-separated genre strings to genres list. + + This migration runs automatically when the genres column is first + created in the database. It splits comma-separated genre values + and writes the changes to both the database and media files. + """ + items = list(self.items()) + migrated_count = 0 + total_items = len(items) + + if total_items == 0: + return + + ui.print_(f"Migrating genres for {total_items} items...") + + for index, item in enumerate(items, 1): + genre_val = item.genre or "" + genres_val = item.genres or [] + + # Check if migration is needed + needs_migration = False + split_genres = [] + if not genres_val and genre_val: + for separator in [", ", "; ", " / "]: + if separator in genre_val: + split_genres = [ + g.strip() + for g in genre_val.split(separator) + if g.strip() + ] + if len(split_genres) > 1: + needs_migration = True + break + + if needs_migration: + migrated_count += 1 + # Show progress every 100 items + if migrated_count % 100 == 0: + ui.print_( + f" Migrated {migrated_count} items " + f"({index}/{total_items} processed)..." + ) + # Migrate using the same logic as correct_list_fields + correct_list_fields(item) + item.store() + # Write to media file + try: + item.try_write() + except Exception as e: + log.warning( + "Could not write genres to {}: {}", + item.path, + e, + ) + + ui.print_( + f"Migration complete: {migrated_count} of {total_items} items " + f"updated with comma-separated genres" + ) diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 8918a10cb..8e93efc3a 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -33,6 +33,7 @@ import beets import beets.ui from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.metadata_plugins import MetadataSourcePlugin +from beets.util import unique_list if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Sequence @@ -235,8 +236,7 @@ class BeatportObject: self.artists = [(x["id"], str(x["name"])) for x in data["artists"]] if "genres" in data: genre_list = [str(x["name"]) for x in data["genres"]] - # Remove duplicates while preserving order - self.genres = list(dict.fromkeys(genre_list)) + self.genres = unique_list(genre_list) def artists_str(self) -> str | None: if self.artists is not None: @@ -255,7 +255,6 @@ class BeatportRelease(BeatportObject): label_name: str | None category: str | None url: str | None - genre: str | None tracks: list[BeatportTrack] | None = None @@ -265,7 +264,6 @@ class BeatportRelease(BeatportObject): self.catalog_number = data.get("catalogNumber") self.label_name = data.get("label", {}).get("name") self.category = data.get("category") - self.genre = data.get("genre") if "slug" in data: self.url = ( @@ -287,7 +285,6 @@ class BeatportTrack(BeatportObject): track_number: int | None bpm: str | None initial_key: str | None - genre: str | None def __init__(self, data: JSONDict): super().__init__(data) @@ -316,8 +313,7 @@ class BeatportTrack(BeatportObject): else: genre_list = [] - # Remove duplicates while preserving order - self.genres = list(dict.fromkeys(genre_list)) + self.genres = unique_list(genre_list) class BeatportPlugin(MetadataSourcePlugin): @@ -490,7 +486,6 @@ class BeatportPlugin(MetadataSourcePlugin): media="Digital", 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, @@ -516,7 +511,6 @@ class BeatportPlugin(MetadataSourcePlugin): data_url=track.url, bpm=track.bpm, initial_key=track.initial_key, - genre=track.genre, genres=track.genres, ) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 1fc191a81..2c9b2ed06 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -39,7 +39,7 @@ from beets.util import plurality, unique_list if TYPE_CHECKING: import optparse - from collections.abc import Callable + from collections.abc import Callable, Iterable from beets.importer import ImportSession, ImportTask from beets.library import LibModel @@ -213,7 +213,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): - Returns an empty list if the input tags list is empty. - If canonicalization is enabled, it extends the list by incorporating parent genres from the canonicalization tree. When a whitelist is set, - only parent tags that pass a validity check (_is_valid) are included; + only parent tags that pass the whitelist filter are included; otherwise, it adds the oldest ancestor. Adding parent tags is stopped when the count of tags reaches the configured limit (count). - The tags list is then deduplicated to ensure only unique genres are @@ -237,11 +237,9 @@ class LastGenrePlugin(plugins.BeetsPlugin): # Add parents that are in the whitelist, or add the oldest # ancestor if no whitelist if self.whitelist: - parents = [ - x - for x in find_parents(tag, self.c14n_branches) - if self._is_valid(x) - ] + parents = self._filter_valid( + find_parents(tag, self.c14n_branches) + ) else: parents = [find_parents(tag, self.c14n_branches)[-1]] @@ -263,7 +261,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): # c14n only adds allowed genres but we may have had forbidden genres in # the original tags list - valid_tags = [t for t in tags if self._is_valid(t)] + valid_tags = self._filter_valid(tags) return valid_tags[:count] def fetch_genre( @@ -275,15 +273,19 @@ class LastGenrePlugin(plugins.BeetsPlugin): min_weight = self.config["min_weight"].get(int) return self._tags_for(lastfm_obj, min_weight) - def _is_valid(self, genre: str) -> bool: - """Check if the genre is valid. + def _filter_valid(self, genres: Iterable[str]) -> list[str]: + """Filter genres based on whitelist. - Depending on the whitelist property, valid means a genre is in the - whitelist or any genre is allowed. + Returns all genres if no whitelist is configured, otherwise returns + only genres that are in the whitelist. """ - if genre and (not self.whitelist or genre.lower() in self.whitelist): - return True - return False + # First, drop any falsy or whitespace-only genre strings to avoid + # retaining empty tags from multi-valued fields. + cleaned = [g for g in genres if g and g.strip()] + if not self.whitelist: + return cleaned + + return [g for g in cleaned if g.lower() in self.whitelist] # Cached last.fm entity lookups. diff --git a/docs/changelog.rst b/docs/changelog.rst index 2a4caf573..290f63168 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -21,12 +21,11 @@ New features updated to populate the ``genres`` field as a list. **Migration**: Existing libraries with comma-separated, semicolon-separated, - or slash-separated genre strings (e.g., ``"Rock, Alternative, Indie"``) will - be automatically migrated to the ``genres`` list when items are accessed. No - manual reimport or ``mbsync`` is required. For users who prefer explicit - control, a new ``beet migrate genres`` command is available to migrate the - entire library at once. Use ``beet migrate genres --pretend`` to preview - changes before applying them. + or slash-separated genre strings (e.g., ``"Rock, Alternative, Indie"``) are + automatically migrated to the ``genres`` list when you first run beets after + upgrading. The migration runs once when the database schema is updated, + splitting genre strings and writing the changes to both the database and media + files. No manual action or ``mbsync`` is required. .. Bug fixes @@ -47,6 +46,10 @@ Other changes - :doc:`plugins/edit`: Editing multi-valued fields now behaves more naturally, with list values handled directly to make metadata edits smoother and more predictable. +- :doc:`plugins/lastgenre`: The ``separator`` configuration option is + deprecated. Genres are now stored as a list in the ``genres`` field and + written to files as individual genre tags. The separator option has no effect + and will be removed in a future version. 2.6.2 (February 22, 2026) ------------------------- diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst index ace7caaf0..b677b001e 100644 --- a/docs/plugins/lastgenre.rst +++ b/docs/plugins/lastgenre.rst @@ -90,9 +90,8 @@ By default, the plugin chooses the most popular tag on Last.fm as a genre. If you prefer to use a *list* of popular genre tags, you can increase the number of the ``count`` config option. -Lists of up to *count* genres will then be used instead of single genres. The -genres are separated by commas by default, but you can change this with the -``separator`` config option. +Lists of up to *count* genres will be stored in the ``genres`` field as a list +and written to your media files as separate genre tags. Last.fm_ provides a popularity factor, a.k.a. *weight*, for each tag ranging from 100 for the most popular tag down to 0 for the least popular. The plugin @@ -192,7 +191,16 @@ file. The available options are: Default: ``no``. - **source**: Which entity to look up in Last.fm. Can be either ``artist``, ``album`` or ``track``. Default: ``album``. -- **separator**: A separator for multiple genres. Default: ``', '``. +- **separator**: + + .. deprecated:: 2.6 + + The ``separator`` option is deprecated. Genres are now stored as a list in + the ``genres`` field and written to files as individual genre tags. This + option has no effect and will be removed in a future version. + + Default: ``', '``. + - **whitelist**: The filename of a custom genre list, ``yes`` to use the internal whitelist, or ``no`` to consider all genres valid. Default: ``yes``. - **title_case**: Convert the new tags to TitleCase before saving. Default: diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index 15d47db6c..cef84e3a9 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -362,7 +362,7 @@ class DGAlbumInfoTest(BeetsTestCase): release = self._make_release_from_positions(["1", "2"]) d = DiscogsPlugin().get_album_info(release) - assert d.genre == "GENRE1, GENRE2" + assert d.genres == ["GENRE1", "GENRE2"] assert d.style == "STYLE1, STYLE2" def test_append_style_to_genre(self): @@ -371,7 +371,7 @@ class DGAlbumInfoTest(BeetsTestCase): release = self._make_release_from_positions(["1", "2"]) d = DiscogsPlugin().get_album_info(release) - assert d.genre == "GENRE1, GENRE2, STYLE1, STYLE2" + assert d.genres == ["GENRE1", "GENRE2", "STYLE1", "STYLE2"] assert d.style == "STYLE1, STYLE2" def test_append_style_to_genre_no_style(self): @@ -381,7 +381,7 @@ class DGAlbumInfoTest(BeetsTestCase): release.data["styles"] = [] d = DiscogsPlugin().get_album_info(release) - assert d.genre == "GENRE1, GENRE2" + assert d.genres == ["GENRE1", "GENRE2"] assert d.style is None def test_strip_disambiguation(self): diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index e218ae307..1a53a5a72 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -401,25 +401,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): }, (["fallback genre"], "fallback"), ), - # 9 - null charachter as separator - ( - { - "force": True, - "keep_existing": True, - "source": "album", - "whitelist": True, - "separator": "\u0000", - "canonical": False, - "prefer_specific": False, - "count": 10, - }, - "Blues", - { - "album": ["Jazz"], - }, - (["Blues", "Jazz"], "keep + album, whitelist"), - ), - # 10 - limit a lot of results + # 9 - limit a lot of results ( { "force": True, @@ -429,7 +411,6 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "count": 5, "canonical": False, "prefer_specific": False, - "separator": ", ", }, "original unknown, Blues, Rock, Folk, Metal", { @@ -440,23 +421,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "keep + album, whitelist", ), ), - # 11 - force off does not rely on configured separator - ( - { - "force": False, - "keep_existing": False, - "source": "album", - "whitelist": True, - "count": 2, - "separator": ", ", - }, - "not ; configured | separator", - { - "album": ["Jazz", "Bebop"], - }, - (["not ; configured | separator"], "keep any, no-force"), - ), - # 12 - fallback to next stage (artist) if no allowed original present + # 10 - fallback to next stage (artist) if no allowed original present # and no album genre were fetched. ( { @@ -476,7 +441,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): }, (["Jazz"], "keep + artist, whitelist"), ), - # 13 - canonicalization transforms non-whitelisted genres to canonical forms + # 11 - canonicalization transforms non-whitelisted genres to canonical forms # # "Acid Techno" is not in the default whitelist, thus gets resolved "up" in the # tree to "Techno" and "Electronic". @@ -496,7 +461,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): }, (["Techno", "Electronic"], "album, whitelist"), ), - # 14 - canonicalization transforms whitelisted genres to canonical forms and + # 12 - canonicalization transforms whitelisted genres to canonical forms and # includes originals # # "Detroit Techno" is in the default whitelist, thus it stays and and also gets @@ -528,7 +493,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "keep + album, whitelist", ), ), - # 15 - canonicalization transforms non-whitelisted original genres to canonical + # 13 - canonicalization transforms non-whitelisted original genres to canonical # forms and deduplication works. # # "Cosmic Disco" is not in the default whitelist, thus gets resolved "up" in the @@ -606,25 +571,11 @@ def test_get_genre(config_values, item_genre, mock_genres, expected_result): plugin.setup() # Loads default whitelist and canonicalization tree item = _common.item() - # Set genres as a list - if item_genre is a string, convert it to list if item_genre: - # For compatibility with old separator-based tests, split if needed - if ( - "separator" in config_values - and config_values["separator"] in item_genre - ): - sep = config_values["separator"] - item.genres = [ - g.strip() for g in item_genre.split(sep) if g.strip() - ] + if ", " in item_genre: + item.genres = [g.strip() for g in item_genre.split(", ")] else: - # Assume comma-separated if no specific separator - if ", " in item_genre: - item.genres = [ - g.strip() for g in item_genre.split(", ") if g.strip() - ] - else: - item.genres = [item_genre] + item.genres = [item_genre] else: item.genres = [] diff --git a/test/test_autotag.py b/test/test_autotag.py index e094d0ddc..e6a122ae2 100644 --- a/test/test_autotag.py +++ b/test/test_autotag.py @@ -543,48 +543,3 @@ class TestGenreSync: assert item.genres == [] assert item.genre == "" - - def test_migrate_comma_separated_genres(self): - """Migrate legacy comma-separated genre strings.""" - item = Item(genre="Rock, Alternative, Indie", genres=[]) - correct_list_fields(item) - - # Should split into genres list - assert item.genres == ["Rock", "Alternative", "Indie"] - # Genre becomes first item after migration - assert item.genre == "Rock" - - def test_migrate_semicolon_separated_genres(self): - """Migrate legacy semicolon-separated genre strings.""" - item = Item(genre="Rock; Alternative; Indie", genres=[]) - correct_list_fields(item) - - assert item.genres == ["Rock", "Alternative", "Indie"] - assert item.genre == "Rock" - - def test_migrate_slash_separated_genres(self): - """Migrate legacy slash-separated genre strings.""" - item = Item(genre="Rock / Alternative / Indie", genres=[]) - correct_list_fields(item) - - assert item.genres == ["Rock", "Alternative", "Indie"] - assert item.genre == "Rock" - - def test_no_migration_when_genres_exists(self): - """Don't migrate if genres list already populated.""" - item = Item(genre="Jazz, Blues", genres=["Rock", "Pop"]) - correct_list_fields(item) - - # Existing genres list should be preserved - # The full genre string is prepended (migration doesn't run when genres exists) - assert item.genres == ["Jazz, Blues", "Rock", "Pop"] - assert item.genre == "Jazz, Blues" - - def test_no_migration_single_genre(self): - """Don't split single genres without separators.""" - item = Item(genre="Rock", genres=[]) - correct_list_fields(item) - - # Single genre (no separator) should not trigger migration - assert item.genres == ["Rock"] - assert item.genre == "Rock" From e99d3ca06151d11919ee992980a37794d593df85 Mon Sep 17 00:00:00 2001 From: dunkla Date: Sun, 28 Dec 2025 20:48:15 +0100 Subject: [PATCH 09/31] Simplify MusicBrainz genres assignment Remove intermediate variable and assign directly to info.genres. Addresses PR review comment. --- beetsplug/musicbrainz.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 695ce8790..75933e6f9 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -644,11 +644,10 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): for source in sources: for genreitem in source: genres[genreitem["name"]] += int(genreitem["count"]) - genre_list = [ + info.genres = [ genre for genre, _count in sorted(genres.items(), key=lambda g: -g[1]) ] - info.genres = genre_list # We might find links to external sources (Discogs, Bandcamp, ...) external_ids = self.config["external_ids"].get() From 76c4eeedbb9903528cf32e8894175142a1cd2367 Mon Sep 17 00:00:00 2001 From: dunkla Date: Sun, 28 Dec 2025 20:54:43 +0100 Subject: [PATCH 10/31] Remove manual migrate command 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. --- beets/ui/commands/__init__.py | 8 ++- beets/ui/commands/migrate.py | 98 ----------------------------------- 2 files changed, 3 insertions(+), 103 deletions(-) delete mode 100644 beets/ui/commands/migrate.py diff --git a/beets/ui/commands/__init__.py b/beets/ui/commands/__init__.py index b4eebb53f..e1d0389a3 100644 --- a/beets/ui/commands/__init__.py +++ b/beets/ui/commands/__init__.py @@ -24,7 +24,6 @@ from .fields import fields_cmd from .help import HelpCommand from .import_ import import_cmd from .list import list_cmd -from .migrate import migrate_cmd from .modify import modify_cmd from .move import move_cmd from .remove import remove_cmd @@ -53,13 +52,12 @@ default_commands = [ HelpCommand(), import_cmd, list_cmd, - migrate_cmd, - modify_cmd, - move_cmd, + update_cmd, remove_cmd, stats_cmd, - update_cmd, version_cmd, + modify_cmd, + move_cmd, write_cmd, config_cmd, completion_cmd, diff --git a/beets/ui/commands/migrate.py b/beets/ui/commands/migrate.py deleted file mode 100644 index 2cb7e0d59..000000000 --- a/beets/ui/commands/migrate.py +++ /dev/null @@ -1,98 +0,0 @@ -"""The 'migrate' command: migrate library data for format changes.""" - -from beets import logging, ui -from beets.autotag import correct_list_fields - -# Global logger. -log = logging.getLogger("beets") - - -def migrate_genres(lib, pretend=False): - """Migrate comma-separated genre strings to genres list. - - For users upgrading from previous versions, their genre field may - contain comma-separated values (e.g., "Rock, Alternative, Indie"). - This command splits those values into the genres list, avoiding - the need to reimport the entire library. - """ - items = lib.items() - migrated_count = 0 - total_items = 0 - - ui.print_("Scanning library for items with comma-separated genres...") - - for item in items: - total_items += 1 - genre_val = item.genre or "" - genres_val = item.genres or [] - - # Check if migration is needed - needs_migration = False - if not genres_val and genre_val: - for separator in [", ", "; ", " / "]: - if separator in genre_val: - split_genres = [ - g.strip() - for g in genre_val.split(separator) - if g.strip() - ] - if len(split_genres) > 1: - needs_migration = True - break - - if needs_migration: - migrated_count += 1 - old_genre = item.genre - old_genres = item.genres or [] - - if pretend: - # Just show what would change - ui.print_( - f" Would migrate: {item.artist} - {item.title}\n" - f" genre: {old_genre!r} -> {split_genres[0]!r}\n" - f" genres: {old_genres!r} -> {split_genres!r}" - ) - else: - # Actually migrate - correct_list_fields(item) - item.store() - log.debug( - "migrated: {} - {} ({} -> {})", - item.artist, - item.title, - old_genre, - item.genres, - ) - - # Show summary - if pretend: - ui.print_( - f"\nWould migrate {migrated_count} of {total_items} items " - f"(run without --pretend to apply changes)" - ) - else: - ui.print_( - f"\nMigrated {migrated_count} of {total_items} items with " - f"comma-separated genres" - ) - - -def migrate_func(lib, opts, args): - """Handle the migrate command.""" - if not args or args[0] == "genres": - migrate_genres(lib, pretend=opts.pretend) - else: - raise ui.UserError(f"unknown migration target: {args[0]}") - - -migrate_cmd = ui.Subcommand( - "migrate", help="migrate library data for format changes" -) -migrate_cmd.parser.add_option( - "-p", - "--pretend", - action="store_true", - help="show what would be changed without applying", -) -migrate_cmd.parser.usage = "%prog migrate genres [options]" -migrate_cmd.func = migrate_func From 21fb5a561d3cf444d3851b75735692647a9a5bae Mon Sep 17 00:00:00 2001 From: dunkla Date: Sat, 10 Jan 2026 15:37:33 +0100 Subject: [PATCH 11/31] Fix lastgenre migration separator logic (ref https://github.com/beetbox/beets/pull/6169#issuecomment-3716893013) --- beets/library/library.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/beets/library/library.py b/beets/library/library.py index a534d26b3..16a8fcf93 100644 --- a/beets/library/library.py +++ b/beets/library/library.py @@ -196,7 +196,26 @@ class Library(dbcore.Database): needs_migration = False split_genres = [] if not genres_val and genre_val: - for separator in [", ", "; ", " / "]: + separators = [] + if ( + "lastgenre" in beets.config + and "separator" in beets.config["lastgenre"] + ): + try: + user_sep = beets.config["lastgenre"][ + "separator" + ].as_str() + if user_sep: + separators.append(user_sep) + except ( + beets.config.ConfigNotFoundError, + beets.config.ConfigTypeError, + ): + pass + + separators.extend([", ", "; ", " / "]) + + for separator in separators: if separator in genre_val: split_genres = [ g.strip() From 9224c9c960f73d044b1d9461f0455aa218c7f7fb Mon Sep 17 00:00:00 2001 From: dunkla Date: Sat, 10 Jan 2026 15:37:45 +0100 Subject: [PATCH 12/31] Use compact generator expression in Beatport (ref https://github.com/beetbox/beets/pull/6169#issuecomment-3716893013) --- beetsplug/beatport.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 8e93efc3a..3368825b8 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -306,14 +306,10 @@ class BeatportTrack(BeatportObject): self.initial_key = str((data.get("key") or {}).get("shortName")) # Extract genres list from subGenres or genres - if data.get("subGenres"): - genre_list = [str(x.get("name")) for x in data["subGenres"]] - elif data.get("genres"): - genre_list = [str(x.get("name")) for x in data["genres"]] - else: - genre_list = [] - - self.genres = unique_list(genre_list) + self.genres = unique_list( + str(x.get("name")) + for x in data.get("subGenres") or data.get("genres") or [] + ) class BeatportPlugin(MetadataSourcePlugin): From 67ce53d2c6e7dc2d187337aab904d4d51412e3f0 Mon Sep 17 00:00:00 2001 From: dunkla Date: Sat, 10 Jan 2026 15:37:55 +0100 Subject: [PATCH 13/31] Remove conditional logic from lastgenre tests (ref https://github.com/beetbox/beets/pull/6169#issuecomment-3716893013) --- test/plugins/test_lastgenre.py | 40 +++++++++++++++------------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 1a53a5a72..5dcf2c165 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -217,7 +217,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "prefer_specific": False, "count": 10, }, - "Blues", + ["Blues"], { "album": ["Jazz"], }, @@ -233,7 +233,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "canonical": False, "prefer_specific": False, }, - "original unknown, Blues", + ["original unknown", "Blues"], { "album": ["Jazz"], }, @@ -249,7 +249,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "canonical": False, "prefer_specific": False, }, - "", + [], { "album": ["Jazz"], }, @@ -265,7 +265,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "canonical": False, "prefer_specific": False, }, - "original unknown, Blues", + ["original unknown", "Blues"], { "album": ["Jazz"], "artist": ["Pop"], @@ -282,7 +282,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "canonical": False, "prefer_specific": False, }, - "any genre", + ["any genre"], { "album": ["Jazz"], }, @@ -298,7 +298,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "canonical": False, "prefer_specific": False, }, - "", + [], { "album": ["Jazzin"], }, @@ -314,7 +314,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "canonical": False, "prefer_specific": False, }, - "unknown genre", + ["unknown genre"], { "track": None, "album": None, @@ -334,7 +334,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "canonical": False, "prefer_specific": False, }, - "any existing", + ["any existing"], { "track": None, "album": None, @@ -354,7 +354,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "canonical": False, "prefer_specific": False, }, - "Jazz", + ["Jazz"], { "track": None, "album": None, @@ -374,7 +374,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "canonical": False, "prefer_specific": False, }, - "Jazz", + ["Jazz"], { "track": None, "album": None, @@ -393,7 +393,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "canonical": False, "prefer_specific": False, }, - "", + [], { "track": None, "album": None, @@ -412,7 +412,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "canonical": False, "prefer_specific": False, }, - "original unknown, Blues, Rock, Folk, Metal", + ["original unknown", "Blues", "Rock", "Folk", "Metal"], { "album": ["Jazz", "Bebop", "Hardbop"], }, @@ -433,7 +433,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "canonical": False, "prefer_specific": False, }, - "not whitelisted original", + ["not whitelisted original"], { "track": None, "album": None, @@ -455,7 +455,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "prefer_specific": False, "count": 10, }, - "", + [], { "album": ["acid techno"], }, @@ -478,7 +478,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "count": 10, "extended_debug": True, }, - "detroit techno", + ["detroit techno"], { "album": ["acid house"], }, @@ -509,7 +509,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "prefer_specific": False, "count": 10, }, - "Cosmic Disco", + ["Cosmic Disco"], { "album": ["Detroit Techno"], }, @@ -571,13 +571,7 @@ def test_get_genre(config_values, item_genre, mock_genres, expected_result): plugin.setup() # Loads default whitelist and canonicalization tree item = _common.item() - if item_genre: - if ", " in item_genre: - item.genres = [g.strip() for g in item_genre.split(", ")] - else: - item.genres = [item_genre] - else: - item.genres = [] + item.genres = item_genre # Run assert plugin._get_genre(item) == expected_result From 9003107ee7c8bfae4985b0792b848634737fc13a Mon Sep 17 00:00:00 2001 From: dunkla Date: Sat, 10 Jan 2026 15:38:04 +0100 Subject: [PATCH 14/31] Remove noisy comments from beatport tests (ref https://github.com/beetbox/beets/pull/6169#issuecomment-3716893013) --- test/plugins/test_beatport.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/plugins/test_beatport.py b/test/plugins/test_beatport.py index b79e4dcc7..916227f40 100644 --- a/test/plugins/test_beatport.py +++ b/test/plugins/test_beatport.py @@ -634,7 +634,6 @@ class BeatportResponseEmptyTest(unittest.TestCase): self.test_tracks[0]["subGenres"] = [] - # BeatportTrack now has genres as a list assert tracks[0].genres == [self.test_tracks[0]["genres"][0]["name"]] def test_genre_empty(self): @@ -644,5 +643,4 @@ class BeatportResponseEmptyTest(unittest.TestCase): self.test_tracks[0]["genres"] = [] - # BeatportTrack now has genres as a list assert tracks[0].genres == [self.test_tracks[0]["subGenres"][0]["name"]] From 10d197e24259aa7dc9046c42772352a31d62747c Mon Sep 17 00:00:00 2001 From: dunkla Date: Sat, 10 Jan 2026 15:39:40 +0100 Subject: [PATCH 15/31] Update lastgenre docstring and remove misleading comment (ref https://github.com/beetbox/beets/pull/6169#issuecomment-3716893013) --- beetsplug/lastgenre/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 2c9b2ed06..7f3d3ea86 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -339,14 +339,12 @@ class LastGenrePlugin(plugins.BeetsPlugin): return tags def _get_existing_genres(self, obj: LibModel) -> list[str]: - """Return a list of genres for this Item or Album. Empty string genres - are removed.""" + """Return a list of genres for this Item or Album.""" if isinstance(obj, library.Item): genres_list = obj.get("genres", with_album=False) else: genres_list = obj.get("genres") - # Filter out empty strings return genres_list def _combine_resolve_and_log( From 0191ecf5764db26c4a86b5b83377f6576f19f9b8 Mon Sep 17 00:00:00 2001 From: dunkla Date: Sun, 11 Jan 2026 14:09:17 +0100 Subject: [PATCH 16/31] Fix mypy incompatible return type in lastgenre --- beetsplug/lastgenre/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 7f3d3ea86..bfa55ba90 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -378,7 +378,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): def _try_resolve_stage( stage_label: str, keep_genres: list[str], new_genres: list[str] - ) -> tuple[str, str] | None: + ) -> tuple[list[str], str] | None: """Try to resolve genres for a given stage and log the result.""" resolved_genres = self._combine_resolve_and_log( keep_genres, new_genres From eac9e1fd973e1eb8cc184413fc44a9d3e23cd06f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 8 Feb 2026 07:09:01 +0000 Subject: [PATCH 17/31] Add support for migrations --- beets/dbcore/db.py | 47 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 8640a5678..deb31ba71 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -26,14 +26,7 @@ import threading import time from abc import ABC from collections import defaultdict -from collections.abc import ( - Callable, - Generator, - Iterable, - Iterator, - Mapping, - Sequence, -) +from collections.abc import Mapping from functools import cached_property from sqlite3 import Connection, sqlite_version_info from typing import TYPE_CHECKING, Any, AnyStr, ClassVar, Generic, NamedTuple @@ -1088,11 +1081,14 @@ class Database: self._db_lock = threading.Lock() # Set up database schema. + self._ensure_migration_state_table() for model_cls in self._models: self._make_table(model_cls._table, model_cls._fields) self._make_attribute_table(model_cls._flex_table) self._create_indices(model_cls._table, model_cls._indices) + self._migrate() + # Primitive access control: connections and transactions. def _connection(self) -> Connection: @@ -1292,6 +1288,41 @@ class Database: f"ON {table} ({', '.join(index.columns)});" ) + # Generic migration state handling. + + def _ensure_migration_state_table(self) -> None: + with self.transaction() as tx: + tx.script(""" + CREATE TABLE IF NOT EXISTS migrations ( + name TEXT NOT NULL, + table_name TEXT NOT NULL, + PRIMARY KEY(name, table_name) + ); + """) + + def _migrate(self) -> None: + """Perform any necessary migration for the database.""" + + def migration_exists(self, name: str, table: str) -> bool: + """Return whether a named migration has been marked complete.""" + with self.transaction() as tx: + return tx.execute( + """ + SELECT EXISTS( + SELECT 1 FROM migrations WHERE name = ? AND table_name = ? + ) + """, + (name, table), + ).fetchone()[0] + + def record_migration(self, name: str, table: str) -> None: + """Set completion state for a named migration.""" + with self.transaction() as tx: + tx.mutate( + "INSERT INTO migrations(name, table_name) VALUES (?, ?)", + (name, table), + ) + # Querying. def _fetch( From 8edd0fc966aef0a44c82bcbe73635ab73aab6f3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 8 Feb 2026 21:24:00 +0000 Subject: [PATCH 18/31] Add generic Migration implementation --- beets/dbcore/db.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index deb31ba71..4b0ba4f15 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -24,9 +24,10 @@ import sqlite3 import sys import threading import time -from abc import ABC +from abc import ABC, abstractmethod from collections import defaultdict from collections.abc import Mapping +from dataclasses import dataclass from functools import cached_property from sqlite3 import Connection, sqlite_version_info from typing import TYPE_CHECKING, Any, AnyStr, ClassVar, Generic, NamedTuple @@ -1029,6 +1030,27 @@ class Transaction: self.db._connection().executescript(statements) +@dataclass +class Migration(ABC): + db: Database + + @cached_classproperty + def name(cls) -> str: + """Class name (except Migration) converted to snake case.""" + name = cls.__name__.removesuffix("Migration") # type: ignore[attr-defined] + return re.sub(r"(?<=[a-z])(?=[A-Z])", "_", name).lower() + + def migrate_table(self, table: str) -> None: + """Migrate a specific table.""" + if not self.db.migration_exists(self.name, table): + self._migrate_data(table) + self.db.record_migration(self.name, table) + + @abstractmethod + def _migrate_data(self, table: str) -> None: + """Migrate data for a specific table.""" + + class Database: """A container for Model objects that wraps an SQLite database as the backend. @@ -1038,6 +1060,9 @@ class Database: """The Model subclasses representing tables in this database. """ + _migrations: Sequence[tuple[type[Migration], Sequence[type[Model]]]] = () + """Migrations that are to be performed for the configured models.""" + supports_extensions = hasattr(sqlite3.Connection, "enable_load_extension") """Whether or not the current version of SQLite supports extensions""" @@ -1302,6 +1327,10 @@ class Database: def _migrate(self) -> None: """Perform any necessary migration for the database.""" + for migration_cls, model_classes in self._migrations: + migration = migration_cls(self) + for model_cls in model_classes: + migration.migrate_table(model_cls._table) def migration_exists(self, name: str, table: str) -> bool: """Return whether a named migration has been marked complete.""" From 2ecbe59f48ddc07bc9ba31c8c16102442ef7870f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 8 Feb 2026 21:28:13 +0000 Subject: [PATCH 19/31] Add migration for multi-value genres field * 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. --- beets/dbcore/db.py | 33 ++++++--- beets/dbcore/types.py | 3 +- beets/library/library.py | 115 ++------------------------------ beets/library/migrations.py | 94 ++++++++++++++++++++++++++ beets/test/helper.py | 2 + test/library/__init__.py | 0 test/library/test_migrations.py | 56 ++++++++++++++++ 7 files changed, 182 insertions(+), 121 deletions(-) create mode 100644 beets/library/migrations.py create mode 100644 test/library/__init__.py create mode 100644 test/library/test_migrations.py diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 4b0ba4f15..af4315267 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -16,7 +16,6 @@ from __future__ import annotations -import contextlib import functools import os import re @@ -27,6 +26,7 @@ import time from abc import ABC, abstractmethod from collections import defaultdict from collections.abc import Mapping +from contextlib import contextmanager from dataclasses import dataclass from functools import cached_property from sqlite3 import Connection, sqlite_version_info @@ -1002,12 +1002,15 @@ class Transaction: cursor = self.db._connection().execute(statement, subvals) return cursor.fetchall() - def mutate(self, statement: str, subvals: Sequence[SQLiteType] = ()) -> Any: - """Execute an SQL statement with substitution values and return - the row ID of the last affected row. + @contextmanager + def _handle_mutate(self) -> Iterator[None]: + """Handle mutation bookkeeping and database access errors. + + Yield control to mutation execution code. If execution succeeds, + mark this transaction as mutated. """ try: - cursor = self.db._connection().execute(statement, subvals) + yield except sqlite3.OperationalError as e: # In two specific cases, SQLite reports an error while accessing # the underlying database file. We surface these exceptions as @@ -1017,11 +1020,23 @@ class Transaction: "unable to open database file", ): raise DBAccessError(e.args[0]) - else: - raise + raise else: self._mutated = True - return cursor.lastrowid + + def mutate(self, statement: str, subvals: Sequence[SQLiteType] = ()) -> Any: + """Run one write statement with shared mutation/error handling.""" + with self._handle_mutate(): + return self.db._connection().execute(statement, subvals).lastrowid + + def mutate_many( + self, statement: str, subvals: Sequence[tuple[SQLiteType, ...]] = () + ) -> Any: + """Run batched writes with shared mutation/error handling.""" + with self._handle_mutate(): + return ( + self.db._connection().executemany(statement, subvals).lastrowid + ) def script(self, statements: str): """Execute a string containing multiple SQL statements.""" @@ -1214,7 +1229,7 @@ class Database: _thread_id, conn = self._connections.popitem() conn.close() - @contextlib.contextmanager + @contextmanager def _tx_stack(self) -> Generator[list[Transaction]]: """A context manager providing access to the current thread's transaction stack. The context manager synchronizes access to diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py index 8907584a4..e50693474 100644 --- a/beets/dbcore/types.py +++ b/beets/dbcore/types.py @@ -30,6 +30,7 @@ from . import query SQLiteType = query.SQLiteType BLOB_TYPE = query.BLOB_TYPE +MULTI_VALUE_DELIMITER = "\\␀" class ModelType(typing.Protocol): @@ -481,4 +482,4 @@ DATE = DateType() SEMICOLON_SPACE_DSV = DelimitedString("; ") # Will set the proper null char in mediafile -MULTI_VALUE_DSV = DelimitedString("\\␀") +MULTI_VALUE_DSV = DelimitedString(MULTI_VALUE_DELIMITER) diff --git a/beets/library/library.py b/beets/library/library.py index 16a8fcf93..b161b7399 100644 --- a/beets/library/library.py +++ b/beets/library/library.py @@ -5,25 +5,22 @@ from typing import TYPE_CHECKING import platformdirs import beets -from beets import dbcore, logging, ui -from beets.autotag import correct_list_fields +from beets import dbcore from beets.util import normpath +from .migrations import MultiGenreFieldMigration from .models import Album, Item from .queries import PF_KEY_DEFAULT, parse_query_parts, parse_query_string if TYPE_CHECKING: - from collections.abc import Mapping - - from beets.dbcore import Results, types - -log = logging.getLogger("beets") + from beets.dbcore import Results class Library(dbcore.Database): """A database of music containing songs and albums.""" _models = (Item, Album) + _migrations = ((MultiGenreFieldMigration, (Item, Album)),) def __init__( self, @@ -147,107 +144,3 @@ class Library(dbcore.Database): item_or_id if isinstance(item_or_id, int) else item_or_id.album_id ) return self._get(Album, album_id) if album_id else None - - # Database schema migration. - - def _make_table(self, table: str, fields: Mapping[str, types.Type]): - """Set up the schema of the database, and migrate genres if needed.""" - with self.transaction() as tx: - rows = tx.query(f"PRAGMA table_info({table})") - current_fields = {row[1] for row in rows} - field_names = set(fields.keys()) - - # Check if genres column is being added to items table - genres_being_added = ( - table == "items" - and "genres" in field_names - and "genres" not in current_fields - and "genre" in current_fields - ) - - # Call parent to create/update table - super()._make_table(table, fields) - - # Migrate genre to genres if genres column was just added - if genres_being_added: - self._migrate_genre_to_genres() - - def _migrate_genre_to_genres(self): - """Migrate comma-separated genre strings to genres list. - - This migration runs automatically when the genres column is first - created in the database. It splits comma-separated genre values - and writes the changes to both the database and media files. - """ - items = list(self.items()) - migrated_count = 0 - total_items = len(items) - - if total_items == 0: - return - - ui.print_(f"Migrating genres for {total_items} items...") - - for index, item in enumerate(items, 1): - genre_val = item.genre or "" - genres_val = item.genres or [] - - # Check if migration is needed - needs_migration = False - split_genres = [] - if not genres_val and genre_val: - separators = [] - if ( - "lastgenre" in beets.config - and "separator" in beets.config["lastgenre"] - ): - try: - user_sep = beets.config["lastgenre"][ - "separator" - ].as_str() - if user_sep: - separators.append(user_sep) - except ( - beets.config.ConfigNotFoundError, - beets.config.ConfigTypeError, - ): - pass - - separators.extend([", ", "; ", " / "]) - - for separator in separators: - if separator in genre_val: - split_genres = [ - g.strip() - for g in genre_val.split(separator) - if g.strip() - ] - if len(split_genres) > 1: - needs_migration = True - break - - if needs_migration: - migrated_count += 1 - # Show progress every 100 items - if migrated_count % 100 == 0: - ui.print_( - f" Migrated {migrated_count} items " - f"({index}/{total_items} processed)..." - ) - # Migrate using the same logic as correct_list_fields - correct_list_fields(item) - item.store() - # Write to media file - try: - item.try_write() - except Exception as e: - log.warning( - "Could not write genres to {}: {}", - item.path, - e, - ) - - ui.print_( - f"Migration complete: {migrated_count} of {total_items} items " - f"updated with comma-separated genres" - ) diff --git a/beets/library/migrations.py b/beets/library/migrations.py new file mode 100644 index 000000000..e2fa80f63 --- /dev/null +++ b/beets/library/migrations.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from contextlib import contextmanager, suppress +from functools import cached_property +from typing import TYPE_CHECKING, NamedTuple, TypeVar + +from confuse.exceptions import ConfigError + +import beets +from beets import ui +from beets.dbcore.db import Migration +from beets.dbcore.types import MULTI_VALUE_DELIMITER +from beets.util import unique_list + +if TYPE_CHECKING: + from collections.abc import Iterator + +T = TypeVar("T") + + +class GenreRow(NamedTuple): + id: int + genre: str + genres: str | None + + +def chunks(lst: list[T], n: int) -> Iterator[list[T]]: + """Yield successive n-sized chunks from lst.""" + for i in range(0, len(lst), n): + yield lst[i : i + n] + + +class MultiGenreFieldMigration(Migration): + @cached_property + def separators(self) -> list[str]: + separators = [] + with suppress(ConfigError): + separators.append(beets.config["lastgenre"]["separator"].as_str()) + + separators.extend([", ", "; ", " / "]) + return unique_list(filter(None, separators)) + + @contextmanager + def with_factory(self, factory: type[NamedTuple]) -> Iterator[None]: + """Temporarily set the row factory to a specific type.""" + original_factory = self.db._connection().row_factory + self.db._connection().row_factory = lambda _, row: factory(*row) + try: + yield + finally: + self.db._connection().row_factory = original_factory + + def get_genres(self, genre: str) -> str: + for separator in self.separators: + if separator in genre: + return genre.replace(separator, MULTI_VALUE_DELIMITER) + + return genre + + def _migrate_data(self, table: str) -> None: + """Migrate legacy genre values to the multi-value genres field.""" + + with self.db.transaction() as tx, self.with_factory(GenreRow): + rows: list[GenreRow] = tx.query( # type: ignore[assignment] + f""" + SELECT id, genre, genres + FROM {table} + WHERE genre IS NOT NULL AND genre != '' + """ + ) + + total = len(rows) + to_migrate = [e for e in rows if not e.genres] + if not to_migrate: + return + + migrated = total - len(to_migrate) + + ui.print_(f"Migrating genres for {total} {table}...") + for batch in chunks(to_migrate, 1000): + with self.db.transaction() as tx: + tx.mutate_many( + f"UPDATE {table} SET genres = ? WHERE id = ?", + [(self.get_genres(e.genre), e.id) for e in batch], + ) + + migrated += len(batch) + + ui.print_( + f" Migrated {migrated} {table} " + f"({migrated}/{total} processed)..." + ) + + ui.print_(f"Migration complete: {migrated} of {total} {table} updated") diff --git a/beets/test/helper.py b/beets/test/helper.py index 7762ab866..218b778c7 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -156,6 +156,8 @@ class TestHelper(RunMixin, ConfigMixin): fixtures. """ + lib: Library + resource_path = Path(os.fsdecode(_common.RSRC)) / "full.mp3" db_on_disk: ClassVar[bool] = False diff --git a/test/library/__init__.py b/test/library/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/library/test_migrations.py b/test/library/test_migrations.py new file mode 100644 index 000000000..dba0d8718 --- /dev/null +++ b/test/library/test_migrations.py @@ -0,0 +1,56 @@ +import pytest + +from beets.library.migrations import MultiGenreFieldMigration +from beets.library.models import Album, Item +from beets.test.helper import TestHelper + + +class TestMultiGenreFieldMigration: + @pytest.fixture + def helper(self, monkeypatch): + # do not apply migrations upon library initialization + monkeypatch.setattr("beets.library.library.Library._migrations", ()) + helper = TestHelper() + helper.setup_beets() + + # and now configure the migrations to be tested + monkeypatch.setattr( + "beets.library.library.Library._migrations", + ((MultiGenreFieldMigration, (Item, Album)),), + ) + yield helper + + helper.teardown_beets() + + def test_migrates_only_rows_with_missing_genres(self, helper: TestHelper): + helper.config["lastgenre"]["separator"] = " - " + + expected_item_genres = [] + for genre, initial_genres, expected_genres in [ + # already existing value is not overwritten + ("Item Rock", ("Ignored",), ("Ignored",)), + ("", (), ()), + ("Rock", (), ("Rock",)), + # multiple genres are split on one of default separators + ("Item Rock; Alternative", (), ("Item Rock", "Alternative")), + # multiple genres are split the first (lastgenre) separator ONLY + ("Item - Rock, Alternative", (), ("Item", "Rock, Alternative")), + ]: + helper.add_item(genre=genre, genres=initial_genres) + expected_item_genres.append(expected_genres) + + unmigrated_album = helper.add_album( + genre="Album Rock / Alternative", genres=[] + ) + expected_item_genres.append(("Album Rock", "Alternative")) + + helper.lib._migrate() + + actual_item_genres = [tuple(i.genres) for i in helper.lib.items()] + assert actual_item_genres == expected_item_genres + + unmigrated_album.load() + assert unmigrated_album.genres == ["Album Rock", "Alternative"] + + assert helper.lib.migration_exists("multi_genre_field", "items") + assert helper.lib.migration_exists("multi_genre_field", "albums") From 4dda8e3e49489f0b1b6e83dbc9a35d9fbc85e0a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 9 Feb 2026 22:28:45 +0000 Subject: [PATCH 20/31] Fix deprecation warning --- beets/autotag/hooks.py | 26 +++++++++----------------- test/autotag/test_hooks.py | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 17 deletions(-) create mode 100644 test/autotag/test_hooks.py diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index ef9e8bb30..617d5051a 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -16,7 +16,6 @@ from __future__ import annotations -import warnings from copy import deepcopy from dataclasses import dataclass from functools import cached_property @@ -25,6 +24,7 @@ from typing import TYPE_CHECKING, Any, TypeVar from typing_extensions import Self from beets.util import cached_classproperty +from beets.util.deprecation import deprecate_for_maintainers if TYPE_CHECKING: from beets.library import Item @@ -81,25 +81,17 @@ class Info(AttrDict[Any]): media: str | None = None, **kwargs, ) -> None: - if genre: - warnings.warn( - "The 'genre' parameter is deprecated. Use 'genres' (list) instead.", - DeprecationWarning, - stacklevel=2, + if genre is not None: + deprecate_for_maintainers( + "The 'genre' parameter", "'genres' (list)", stacklevel=3 ) if not genres: - for separator in [", ", "; ", " / "]: - if separator in genre: - split_genres = [ - g.strip() - for g in genre.split(separator) - if g.strip() - ] - if len(split_genres) > 1: - genres = split_genres - break - if not genres: + try: + sep = next(s for s in [", ", "; ", " / "] if s in genre) + except StopIteration: genres = [genre] + else: + genres = list(map(str.strip, genre.split(sep))) self.album = album self.artist = artist diff --git a/test/autotag/test_hooks.py b/test/autotag/test_hooks.py new file mode 100644 index 000000000..e5de089e8 --- /dev/null +++ b/test/autotag/test_hooks.py @@ -0,0 +1,17 @@ +import pytest + +from beets.autotag.hooks import Info + + +@pytest.mark.parametrize( + "genre, expected_genres", + [ + ("Rock", ("Rock",)), + ("Rock; Alternative", ("Rock", "Alternative")), + ], +) +def test_genre_deprecation(genre, expected_genres): + with pytest.warns( + DeprecationWarning, match="The 'genre' parameter is deprecated" + ): + assert tuple(Info(genre=genre).genres) == expected_genres From cf36ed07546d78c27df6d1d4d57ebda0d1750be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 9 Feb 2026 22:54:24 +0000 Subject: [PATCH 21/31] Only handle multiple genres in discogs --- beetsplug/discogs/__init__.py | 23 ++++++++--------------- docs/plugins/discogs.rst | 18 +++++++++++------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/beetsplug/discogs/__init__.py b/beetsplug/discogs/__init__.py index bdbeb8fc0..b33af83a2 100644 --- a/beetsplug/discogs/__init__.py +++ b/beetsplug/discogs/__init__.py @@ -352,12 +352,11 @@ class DiscogsPlugin(MetadataSourcePlugin): mediums = [t["medium"] for t in tracks] country = result.data.get("country") data_url = result.data.get("uri") - style = self.format(result.data.get("styles")) - base_genre = self.format(result.data.get("genres")) + styles: list[str] = result.data.get("styles") or [] + genres: list[str] = result.data.get("genres") or [] - genre = base_genre - if self.config["append_style_genre"] and genre is not None and style: - genre += f"{self.config['separator'].as_str()}{style}" + if self.config["append_style_genre"]: + genres.extend(styles) discogs_albumid = self._extract_id(result.data.get("uri")) @@ -411,8 +410,10 @@ class DiscogsPlugin(MetadataSourcePlugin): releasegroup_id=master_id, catalognum=catalogno, country=country, - style=style, - genre=genre, + style=( + self.config["separator"].as_str().join(sorted(styles)) or None + ), + genres=sorted(genres), media=media, original_year=original_year, data_source=self.data_source, @@ -433,14 +434,6 @@ class DiscogsPlugin(MetadataSourcePlugin): return None - def format(self, classification: Iterable[str]) -> str | None: - if classification: - return ( - self.config["separator"].as_str().join(sorted(classification)) - ) - else: - return None - def get_tracks( self, tracklist: list[Track], diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index 780042026..3734b57e7 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -116,17 +116,21 @@ Default .. conf:: append_style_genre :default: no - Appends the Discogs style (if found) to the genre tag. This can be useful if - you want more granular genres to categorize your music. For example, - a release in Discogs might have a genre of "Electronic" and a style of - "Techno": enabling this setting would set the genre to be "Electronic, - Techno" (assuming default separator of ``", "``) instead of just - "Electronic". + Appends the Discogs style (if found) to the ``genres`` tag. This can be + useful if you want more granular genres to categorize your music. For + example, a release in Discogs might have a genre of "Electronic" and a style + of "Techno": enabling this setting would append "Techno" to the ``genres`` + list. .. conf:: separator :default: ", " - How to join multiple genre and style values from Discogs into a string. + How to join multiple style values from Discogs into a string. + + .. versionchanged:: 2.7.0 + + This option now only applies to the ``style`` field as beets now only + handles lists of ``genres``. .. conf:: strip_disambiguation :default: yes From b8f1b9d174afb353b93e7ee3f04c31cb40fdd796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 10 Feb 2026 01:47:45 +0000 Subject: [PATCH 22/31] Stop overwriting this test file name --- test/rsrc/unicode’d.mp3 | Bin 75297 -> 75297 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/test/rsrc/unicode’d.mp3 b/test/rsrc/unicode’d.mp3 index f7e8b6285ac6eb3d606a6f7fcaee76a4f8f9e735..2b306cc13e8cdf9592b1b5a6f45c4201c2dd3861 100644 GIT binary patch delta 46 zcmZ2@hGpRymJN}NQXvr$5ey6rf(#7IK8{YVJ`5!psR}uXNvS!TBN%V|+U))3nkE2F Ce-IA< delta 23 fcmZ2@hGpRymJN}Nn@bq4|C*feLwd9KpKF=`iG2&@ From 5d7fb4e1586c5fa5c4e79a6010dd65ca37e3c37b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 10 Feb 2026 01:49:10 +0000 Subject: [PATCH 23/31] Remove genre field --- beets/autotag/__init__.py | 1 - beets/dbcore/db.py | 86 +++++++++++++++++++++------------ beets/library/migrations.py | 5 +- beets/library/models.py | 7 +-- beetsplug/bpd/__init__.py | 5 +- beetsplug/smartplaylist.py | 4 +- test/library/test_migrations.py | 16 ++++++ test/test_autotag.py | 68 -------------------------- test/test_library.py | 28 ----------- 9 files changed, 83 insertions(+), 137 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 4cc4ff30a..feeefbf28 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -167,7 +167,6 @@ def correct_list_fields(m: LibModel) -> None: setattr(m, single_field, list_val[0]) ensure_first_value("albumtype", "albumtypes") - ensure_first_value("genre", "genres") if hasattr(m, "mb_artistids"): ensure_first_value("mb_artistid", "mb_artistids") diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index af4315267..cec6abc46 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -30,7 +30,16 @@ from contextlib import contextmanager from dataclasses import dataclass from functools import cached_property from sqlite3 import Connection, sqlite_version_info -from typing import TYPE_CHECKING, Any, AnyStr, ClassVar, Generic, NamedTuple +from typing import ( + TYPE_CHECKING, + Any, + AnyStr, + ClassVar, + Generic, + Literal, + NamedTuple, + TypedDict, +) from typing_extensions import ( Self, @@ -1055,17 +1064,22 @@ class Migration(ABC): name = cls.__name__.removesuffix("Migration") # type: ignore[attr-defined] return re.sub(r"(?<=[a-z])(?=[A-Z])", "_", name).lower() - def migrate_table(self, table: str) -> None: + 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) + self._migrate_data(table, *args, **kwargs) self.db.record_migration(self.name, table) @abstractmethod - def _migrate_data(self, table: str) -> None: + def _migrate_data(self, table: str, current_fields: set[str]) -> None: """Migrate data for a specific table.""" +class TableInfo(TypedDict): + columns: set[str] + migrations: set[str] + + class Database: """A container for Model objects that wraps an SQLite database as the backend. @@ -1129,6 +1143,32 @@ class Database: self._migrate() + @cached_property + def db_tables(self) -> dict[str, TableInfo]: + column_queries = [ + f""" + SELECT '{m._table}' AS table_name, 'columns' AS source, name + FROM pragma_table_info('{m._table}') + """ + for m in self._models + ] + with self.transaction() as tx: + rows = tx.query(f""" + {" UNION ALL ".join(column_queries)} + UNION ALL + SELECT table_name, 'migrations' AS source, name FROM migrations + """) + + tables_data: dict[str, TableInfo] = defaultdict( + lambda: TableInfo(columns=set(), migrations=set()) + ) + + source: Literal["columns", "migrations"] + for table_name, source, name in rows: + tables_data[table_name][source].add(name) + + return tables_data + # Primitive access control: connections and transactions. def _connection(self) -> Connection: @@ -1269,36 +1309,27 @@ class Database: """Set up the schema of the database. `fields` is a mapping from field names to `Type`s. Columns are added if necessary. """ - # Get current schema. - with self.transaction() as tx: - rows = tx.query(f"PRAGMA table_info({table})") - current_fields = {row[1] for row in rows} - - field_names = set(fields.keys()) - if current_fields.issuperset(field_names): - # Table exists and has all the required columns. - return - - if not current_fields: + if table not in self.db_tables: # No table exists. columns = [] for name, typ in fields.items(): columns.append(f"{name} {typ.sql}") setup_sql = f"CREATE TABLE {table} ({', '.join(columns)});\n" - else: # Table exists does not match the field set. setup_sql = "" + current_fields = self.db_tables[table]["columns"] for name, typ in fields.items(): - if name in current_fields: - continue - setup_sql += ( - f"ALTER TABLE {table} ADD COLUMN {name} {typ.sql};\n" - ) + if name not in current_fields: + setup_sql += ( + f"ALTER TABLE {table} ADD COLUMN {name} {typ.sql};\n" + ) with self.transaction() as tx: tx.script(setup_sql) + self.db_tables[table]["columns"] = set(fields) + def _make_attribute_table(self, flex_table: str): """Create a table and associated index for flexible attributes for the given entity (if they don't exist). @@ -1345,19 +1376,12 @@ class Database: for migration_cls, model_classes in self._migrations: migration = migration_cls(self) for model_cls in model_classes: - migration.migrate_table(model_cls._table) + table = model_cls._table + migration.migrate_table(table, self.db_tables[table]["columns"]) def migration_exists(self, name: str, table: str) -> bool: """Return whether a named migration has been marked complete.""" - with self.transaction() as tx: - return tx.execute( - """ - SELECT EXISTS( - SELECT 1 FROM migrations WHERE name = ? AND table_name = ? - ) - """, - (name, table), - ).fetchone()[0] + return name in self.db_tables[table]["migrations"] def record_migration(self, name: str, table: str) -> None: """Set completion state for a named migration.""" diff --git a/beets/library/migrations.py b/beets/library/migrations.py index e2fa80f63..16f4c6761 100644 --- a/beets/library/migrations.py +++ b/beets/library/migrations.py @@ -57,8 +57,11 @@ class MultiGenreFieldMigration(Migration): return genre - def _migrate_data(self, table: str) -> None: + def _migrate_data(self, table: str, current_fields: set[str]) -> None: """Migrate legacy genre values to the multi-value genres field.""" + if "genre" not in current_fields: + # No legacy genre field, so nothing to migrate. + return with self.db.transaction() as tx, self.with_factory(GenreRow): rows: list[GenreRow] = tx.query( # type: ignore[assignment] diff --git a/beets/library/models.py b/beets/library/models.py index 71445c203..9b8b6d291 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -241,7 +241,6 @@ class Album(LibModel): "albumartists_sort": types.MULTI_VALUE_DSV, "albumartists_credit": types.MULTI_VALUE_DSV, "album": types.STRING, - "genre": types.STRING, "genres": types.MULTI_VALUE_DSV, "style": types.STRING, "discogs_albumid": types.INTEGER, @@ -277,7 +276,7 @@ class Album(LibModel): "original_day": types.PaddedInt(2), } - _search_fields = ("album", "albumartist", "genre") + _search_fields = ("album", "albumartist", "genres") @cached_classproperty def _types(cls) -> dict[str, types.Type]: @@ -298,7 +297,6 @@ class Album(LibModel): "albumartist_credit", "albumartists_credit", "album", - "genre", "genres", "style", "discogs_albumid", @@ -652,7 +650,6 @@ class Item(LibModel): "albumartists_sort": types.MULTI_VALUE_DSV, "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, @@ -735,7 +732,7 @@ class Item(LibModel): "comments", "album", "albumartist", - "genre", + "genres", ) # Set of item fields that are backed by `MediaFile` fields. diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index 9496e9a78..835848d42 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -1137,7 +1137,10 @@ class Server(BaseServer): pass for tagtype, field in self.tagtype_map.items(): - info_lines.append(f"{tagtype}: {getattr(item, field)}") + field_value = getattr(item, field) + if isinstance(field_value, list): + field_value = "; ".join(field_value) + info_lines.append(f"{tagtype}: {field_value}") return info_lines diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index a5cc8e362..ff5e25612 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -359,8 +359,8 @@ class SmartPlaylistPlugin(BeetsPlugin): if extm3u: attr = [(k, entry.item[k]) for k in keys] al = [ - f' {key}="{quote(str(value), safe="/:")}"' - for key, value in attr + f' {k}="{quote("; ".join(v) if isinstance(v, list) else str(v), safe="/:")}"' # noqa: E501 + for k, v in attr ] attrs = "".join(al) comment = ( diff --git a/test/library/test_migrations.py b/test/library/test_migrations.py index dba0d8718..2c0dece8b 100644 --- a/test/library/test_migrations.py +++ b/test/library/test_migrations.py @@ -1,5 +1,6 @@ import pytest +from beets.dbcore import types from beets.library.migrations import MultiGenreFieldMigration from beets.library.models import Album, Item from beets.test.helper import TestHelper @@ -10,6 +11,19 @@ class TestMultiGenreFieldMigration: def helper(self, monkeypatch): # do not apply migrations upon library initialization monkeypatch.setattr("beets.library.library.Library._migrations", ()) + # add genre field to both models to make sure this column is created + monkeypatch.setattr( + "beets.library.models.Item._fields", + {**Item._fields, "genre": types.STRING}, + ) + monkeypatch.setattr( + "beets.library.models.Album._fields", + {**Album._fields, "genre": types.STRING}, + ) + monkeypatch.setattr( + "beets.library.models.Album.item_keys", + {*Album.item_keys, "genre"}, + ) helper = TestHelper() helper.setup_beets() @@ -52,5 +66,7 @@ class TestMultiGenreFieldMigration: unmigrated_album.load() assert unmigrated_album.genres == ["Album Rock", "Alternative"] + # remove cached initial db tables data + del helper.lib.db_tables assert helper.lib.migration_exists("multi_genre_field", "items") assert helper.lib.migration_exists("multi_genre_field", "albums") diff --git a/test/test_autotag.py b/test/test_autotag.py index e6a122ae2..119ca15e8 100644 --- a/test/test_autotag.py +++ b/test/test_autotag.py @@ -475,71 +475,3 @@ def test_correct_list_fields( single_val, list_val = item[single_field], item[list_field] assert (not single_val and not list_val) or single_val == list_val[0] - - -# Tests for multi-value genres functionality -class TestGenreSync: - """Test the genre/genres field synchronization.""" - - def test_genres_list_to_genre_first(self): - """Genres list sets genre to first item.""" - item = Item(genres=["Rock", "Alternative", "Indie"]) - correct_list_fields(item) - - assert item.genre == "Rock" - assert item.genres == ["Rock", "Alternative", "Indie"] - - def test_genre_string_to_genres_list(self): - """Genre string becomes first item in genres list.""" - item = Item(genre="Rock") - correct_list_fields(item) - - assert item.genre == "Rock" - assert item.genres == ["Rock"] - - def test_genre_and_genres_both_present(self): - """When both genre and genres exist, genre becomes first in list.""" - item = Item(genre="Jazz", genres=["Rock", "Alternative"]) - correct_list_fields(item) - - # genre should be prepended to genres list (deduplicated) - assert item.genre == "Jazz" - assert item.genres == ["Jazz", "Rock", "Alternative"] - - def test_empty_genre(self): - """Empty genre field.""" - item = Item(genre="") - correct_list_fields(item) - - assert item.genre == "" - assert item.genres == [] - - def test_empty_genres(self): - """Empty genres list.""" - item = Item(genres=[]) - correct_list_fields(item) - - assert item.genre == "" - assert item.genres == [] - - def test_none_values(self): - """Handle None values in genre/genres fields without errors.""" - # Test with None genre - item = Item(genre=None, genres=["Rock"]) - correct_list_fields(item) - assert item.genres == ["Rock"] - assert item.genre == "Rock" - - # Test with None genres - item = Item(genre="Jazz", genres=None) - correct_list_fields(item) - assert item.genre == "Jazz" - assert item.genres == ["Jazz"] - - def test_none_both(self): - """Handle None in both genre and genres.""" - item = Item(genre=None, genres=None) - correct_list_fields(item) - - assert item.genres == [] - assert item.genre == "" diff --git a/test/test_library.py b/test/test_library.py index bf3508e88..4df4e4b58 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -701,34 +701,6 @@ class DestinationFunctionTest(BeetsTestCase, PathFormattingMixin): self._setf("%first{Alice / Bob / Eve,2,0, / , & }") self._assert_dest(b"/base/Alice & Bob") - def test_first_genres_list(self): - # Test that setting genres as a list syncs to genre field properly - # and works with %first template function - from beets.autotag import correct_list_fields - - # Clear the default genre first - self.i.genre = "" - self.i.genres = ["Pop", "Rock", "Classical Crossover"] - correct_list_fields(self.i) - # genre field should now be synced to first item - assert self.i.genre == "Pop" - # %first should work on the genre field - self._setf("%first{$genre}") - self._assert_dest(b"/base/Pop") - - def test_first_genres_list_skip(self): - # Test that the genres list is accessible as a multi-value field - from beets.autotag import correct_list_fields - - # Clear the default genre first - self.i.genre = "" - self.i.genres = ["Pop", "Rock", "Classical Crossover"] - correct_list_fields(self.i) - # Access the second genre directly using index (genres is a list) - # The genres field should be available as a multi-value field - assert self.i.genres[1] == "Rock" - assert len(self.i.genres) == 3 - class DisambiguationTest(BeetsTestCase, PathFormattingMixin): def setUp(self): From a8d53f78de71a32ea0e3ed15099726728877ce30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 15 Feb 2026 13:17:22 +0000 Subject: [PATCH 24/31] Fix the rest of the tests --- beets/test/_common.py | 2 +- beetsplug/aura.py | 6 ++++-- beetsplug/bpd/__init__.py | 2 +- beetsplug/fish.py | 4 ++-- beetsplug/musicbrainz.py | 11 ++++++---- docs/plugins/smartplaylist.rst | 4 ++-- docs/reference/cli.rst | 4 ++-- docs/reference/config.rst | 10 +++++----- test/plugins/test_beatport.py | 4 ++-- test/plugins/test_ihate.py | 8 ++++---- test/plugins/test_musicbrainz.py | 2 +- test/plugins/test_smartplaylist.py | 14 ++++++------- test/test_importer.py | 22 ++++++++++---------- test/test_library.py | 12 ++++++----- test/test_query.py | 32 ++++++++++++------------------ test/test_sort.py | 20 +++++++++---------- test/ui/commands/test_list.py | 2 +- test/ui/commands/test_update.py | 18 ++++++++--------- test/ui/test_field_diff.py | 4 ++-- 19 files changed, 90 insertions(+), 91 deletions(-) diff --git a/beets/test/_common.py b/beets/test/_common.py index 4de47c337..b3c21aaa5 100644 --- a/beets/test/_common.py +++ b/beets/test/_common.py @@ -75,7 +75,7 @@ def item(lib=None, **kwargs): artist="the artist", albumartist="the album artist", album="the album", - genre="the genre", + genres=["the genre"], lyricist="the lyricist", composer="the composer", arranger="the arranger", diff --git a/beetsplug/aura.py b/beetsplug/aura.py index c1877db82..b30e66bf0 100644 --- a/beetsplug/aura.py +++ b/beetsplug/aura.py @@ -79,7 +79,8 @@ TRACK_ATTR_MAP = { "month": "month", "day": "day", "bpm": "bpm", - "genre": "genre", + "genre": "genres", + "genres": "genres", "recording-mbid": "mb_trackid", # beets trackid is MB recording "track-mbid": "mb_releasetrackid", "composer": "composer", @@ -109,7 +110,8 @@ ALBUM_ATTR_MAP = { "year": "year", "month": "month", "day": "day", - "genre": "genre", + "genre": "genres", + "genres": "genres", "release-mbid": "mb_albumid", "release-group-mbid": "mb_releasegroupid", } diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index 835848d42..16eb8c572 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -1354,7 +1354,7 @@ class Server(BaseServer): "AlbumArtist": "albumartist", "AlbumArtistSort": "albumartist_sort", "Label": "label", - "Genre": "genre", + "Genre": "genres", "Date": "year", "OriginalDate": "original_year", "Composer": "composer", diff --git a/beetsplug/fish.py b/beetsplug/fish.py index 82e035eb4..9de764656 100644 --- a/beetsplug/fish.py +++ b/beetsplug/fish.py @@ -16,10 +16,10 @@ """This plugin generates tab completions for Beets commands for the Fish shell , including completions for Beets commands, plugin commands, and option flags. Also generated are completions for all the album -and track fields, suggesting for example `genre:` or `album:` when querying the +and track fields, suggesting for example `genres:` or `album:` when querying the Beets database. Completions for the *values* of those fields are not generated by default but can be added via the `-e` / `--extravalues` flag. For example: -`beet fish -e genre -e albumartist` +`beet fish -e genres -e albumartist` """ import os diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 75933e6f9..090bd617a 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -644,10 +644,13 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): for source in sources: for genreitem in source: genres[genreitem["name"]] += int(genreitem["count"]) - info.genres = [ - genre - for genre, _count in sorted(genres.items(), key=lambda g: -g[1]) - ] + if genres: + info.genres = [ + genre + for genre, _count in sorted( + genres.items(), key=lambda g: -g[1] + ) + ] # We might find links to external sources (Discogs, Bandcamp, ...) external_ids = self.config["external_ids"].get() diff --git a/docs/plugins/smartplaylist.rst b/docs/plugins/smartplaylist.rst index f227559a8..48060ea79 100644 --- a/docs/plugins/smartplaylist.rst +++ b/docs/plugins/smartplaylist.rst @@ -121,7 +121,7 @@ instance the following configuration exports the ``id`` and ``genre`` fields: output: extm3u fields: - id - - genre + - genres playlists: - name: all.m3u query: '' @@ -132,7 +132,7 @@ look as follows: :: #EXTM3U - #EXTINF:805 id="1931" genre="Progressive%20Rock",Led Zeppelin - Stairway to Heaven + #EXTINF:805 id="1931" genres="Rock%3B%20Pop",Led Zeppelin - Stairway to Heaven ../music/singles/Led Zeppelin/Stairway to Heaven.mp3 To give a usage example, the webm3u_ and Beetstream_ plugins read the exported diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 15024022b..6f60d2232 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -143,9 +143,9 @@ Optional command flags: :ref:`set_fields` configuration dictionary. You can use the option multiple times on the command line, like so: - :: +.. code-block:: sh - beet import --set genre="Alternative Rock" --set mood="emotional" + beet import --set genres="Alternative Rock" --set mood="emotional" .. _py7zr: https://pypi.org/project/py7zr/ diff --git a/docs/reference/config.rst b/docs/reference/config.rst index b654c118f..fc0de37a7 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -853,11 +853,11 @@ set_fields A dictionary indicating fields to set to values for newly imported music. Here's an example: -:: +.. code-block:: yaml set_fields: - genre: 'To Listen' - collection: 'Unordered' + genres: To Listen + collection: Unordered Other field/value pairs supplied via the ``--set`` option on the command-line override any settings here for fields with the same name. @@ -1172,9 +1172,9 @@ Here's an example file: color: yes paths: - default: $genre/$albumartist/$album/$track $title + default: %first{$genres}/$albumartist/$album/$track $title singleton: Singletons/$artist - $title - comp: $genre/$album/$track $title + comp: %first{$genres}/$album/$track $title albumtype:soundtrack: Soundtracks/$album/$track $title .. only:: man diff --git a/test/plugins/test_beatport.py b/test/plugins/test_beatport.py index 916227f40..442f80037 100644 --- a/test/plugins/test_beatport.py +++ b/test/plugins/test_beatport.py @@ -474,7 +474,7 @@ class BeatportTest(BeetsTestCase): item.year = 2016 item.comp = False item.label_name = "Gravitas Recordings" - item.genre = "Glitch Hop" + item.genres = ["Glitch Hop"] item.year = 2016 item.month = 4 item.day = 11 @@ -583,7 +583,7 @@ class BeatportTest(BeetsTestCase): def test_genre_applied(self): for track, test_track in zip(self.tracks, self.test_tracks): - assert track.genres == [test_track.genre] + assert track.genres == test_track.genres class BeatportResponseEmptyTest(unittest.TestCase): diff --git a/test/plugins/test_ihate.py b/test/plugins/test_ihate.py index f941d566c..b64b8d91d 100644 --- a/test/plugins/test_ihate.py +++ b/test/plugins/test_ihate.py @@ -11,7 +11,7 @@ class IHatePluginTest(unittest.TestCase): def test_hate(self): match_pattern = {} test_item = Item( - genre="TestGenre", album="TestAlbum", artist="TestArtist" + genres=["TestGenre"], album="TestAlbum", artist="TestArtist" ) task = importer.SingletonImportTask(None, test_item) @@ -27,19 +27,19 @@ class IHatePluginTest(unittest.TestCase): assert IHatePlugin.do_i_hate_this(task, match_pattern) # Query is blocked by AND clause. - match_pattern = ["album:notthis genre:testgenre"] + match_pattern = ["album:notthis genres:testgenre"] assert not IHatePlugin.do_i_hate_this(task, match_pattern) # Both queries are blocked by AND clause with unmatched condition. match_pattern = [ - "album:notthis genre:testgenre", + "album:notthis genres:testgenre", "artist:testartist album:notthis", ] assert not IHatePlugin.do_i_hate_this(task, match_pattern) # Only one query should fire. match_pattern = [ - "album:testalbum genre:testgenre", + "album:testalbum genres:testgenre", "artist:testartist album:notthis", ] assert IHatePlugin.do_i_hate_this(task, match_pattern) diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 4ebce1b01..e000e16ec 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -539,7 +539,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): config["musicbrainz"]["genres"] = False release = self._make_release() d = self.mb.album_info(release) - assert d.genre is None + assert d.genres == [] def test_ignored_media(self): config["match"]["ignored_media"] = ["IGNORED1", "IGNORED2"] diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index 7cc712330..d1125158f 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -76,11 +76,11 @@ class SmartPlaylistTest(BeetsTestCase): {"name": "one_non_empty_sort", "query": ["foo year+", "bar"]}, { "name": "multiple_sorts", - "query": ["foo year+", "bar genre-"], + "query": ["foo year+", "bar genres-"], }, { "name": "mixed", - "query": ["foo year+", "bar", "baz genre+ id-"], + "query": ["foo year+", "bar", "baz genres+ id-"], }, ] ) @@ -102,11 +102,11 @@ class SmartPlaylistTest(BeetsTestCase): # Multiple queries store individual sorts in the tuple assert all(isinstance(x, NullSort) for x in sorts["only_empty_sorts"]) assert sorts["one_non_empty_sort"] == [sort("year"), NullSort()] - assert sorts["multiple_sorts"] == [sort("year"), sort("genre", False)] + assert sorts["multiple_sorts"] == [sort("year"), sort("genres", False)] assert sorts["mixed"] == [ sort("year"), NullSort(), - MultipleSort([sort("genre"), sort("id", False)]), + MultipleSort([sort("genres"), sort("id", False)]), ] def test_matches(self): @@ -259,7 +259,7 @@ class SmartPlaylistTest(BeetsTestCase): type(i).title = PropertyMock(return_value="fake Title") type(i).length = PropertyMock(return_value=300.123) type(i).path = PropertyMock(return_value=b"/tagada.mp3") - a = {"id": 456, "genre": "Fake Genre"} + a = {"id": 456, "genres": ["Rock", "Pop"]} i.__getitem__.side_effect = a.__getitem__ i.evaluate_template.side_effect = lambda pl, _: pl.replace( b"$title", @@ -280,7 +280,7 @@ class SmartPlaylistTest(BeetsTestCase): config["smartplaylist"]["output"] = "extm3u" config["smartplaylist"]["relative_to"] = False config["smartplaylist"]["playlist_dir"] = str(dir) - config["smartplaylist"]["fields"] = ["id", "genre"] + config["smartplaylist"]["fields"] = ["id", "genres"] try: spl.update_playlists(lib) except Exception: @@ -297,7 +297,7 @@ class SmartPlaylistTest(BeetsTestCase): assert content == ( b"#EXTM3U\n" - b'#EXTINF:300 id="456" genre="Fake%20Genre",Fake Artist - fake Title\n' + b'#EXTINF:300 id="456" genres="Rock%3B%20Pop",Fake Artist - fake Title\n' b"/tagada.mp3\n" ) diff --git a/test/test_importer.py b/test/test_importer.py index 6ae7d562b..a560ca5af 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -310,7 +310,7 @@ class ImportSingletonTest(AutotagImportTestCase): config["import"]["set_fields"] = { "collection": collection, - "genre": genre, + "genres": genre, "title": "$title - formatted", "disc": disc, } @@ -322,7 +322,7 @@ class ImportSingletonTest(AutotagImportTestCase): for item in self.lib.items(): item.load() # TODO: Not sure this is necessary. - assert item.genre == genre + assert item.genres == [genre] assert item.collection == collection assert item.title == "Tag Track 1 - formatted" assert item.disc == disc @@ -337,7 +337,7 @@ class ImportSingletonTest(AutotagImportTestCase): for item in self.lib.items(): item.load() - assert item.genre == genre + assert item.genres == [genre] assert item.collection == collection assert item.title == "Applied Track 1 - formatted" assert item.disc == disc @@ -373,12 +373,12 @@ class ImportTest(PathsMixin, AutotagImportTestCase): config["import"]["from_scratch"] = True for mediafile in self.import_media: - mediafile.genre = "Tag Genre" + mediafile.genres = ["Tag Genre"] mediafile.save() self.importer.add_choice(importer.Action.APPLY) self.importer.run() - assert self.lib.items().get().genre == "" + assert not self.lib.items().get().genres def test_apply_from_scratch_keeps_format(self): config["import"]["from_scratch"] = True @@ -470,7 +470,7 @@ class ImportTest(PathsMixin, AutotagImportTestCase): disc = 0 config["import"]["set_fields"] = { - "genre": genre, + "genres": genre, "collection": collection, "comments": comments, "album": "$album - formatted", @@ -483,11 +483,10 @@ class ImportTest(PathsMixin, AutotagImportTestCase): self.importer.run() for album in self.lib.albums(): - album.load() # TODO: Not sure this is necessary. - assert album.genre == genre + assert album.genres == [genre] assert album.comments == comments for item in album.items(): - assert item.get("genre", with_album=False) == genre + assert item.get("genres", with_album=False) == [genre] assert item.get("collection", with_album=False) == collection assert item.get("comments", with_album=False) == comments assert ( @@ -505,11 +504,10 @@ class ImportTest(PathsMixin, AutotagImportTestCase): self.importer.run() for album in self.lib.albums(): - album.load() - assert album.genre == genre + assert album.genres == [genre] assert album.comments == comments for item in album.items(): - assert item.get("genre", with_album=False) == genre + assert item.get("genres", with_album=False) == [genre] assert item.get("collection", with_album=False) == collection assert item.get("comments", with_album=False) == comments assert ( diff --git a/test/test_library.py b/test/test_library.py index 4df4e4b58..de7ff693b 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -66,15 +66,17 @@ class StoreTest(ItemInDBTestCase): assert new_year == 1987 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 def test_store_clears_dirty_flags(self): self.i.composer = "tvp" diff --git a/test/test_query.py b/test/test_query.py index 0ddf83e3a..81532c436 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -71,7 +71,7 @@ class TestGet: album="baz", year=2001, comp=True, - genre="rock", + genres=["rock"], ), helper.create_item( title="second", @@ -80,7 +80,7 @@ class TestGet: album="baz", year=2002, comp=True, - genre="Rock", + genres=["Rock"], ), ] album = helper.lib.add_album(album_items) @@ -94,7 +94,7 @@ class TestGet: album="foo", year=2003, comp=False, - genre="Hard Rock", + genres=["Hard Rock"], comments="caf\xe9", ) @@ -125,12 +125,12 @@ class TestGet: ("comments:caf\xe9", ["third"]), ("comp:true", ["first", "second"]), ("comp:false", ["third"]), - ("genre:=rock", ["first"]), - ("genre:=Rock", ["second"]), - ('genre:="Hard Rock"', ["third"]), - ('genre:=~"hard rock"', ["third"]), - ("genre:=~rock", ["first", "second"]), - ('genre:="hard rock"', []), + ("genres:=rock", ["first"]), + ("genres:=Rock", ["second"]), + ('genres:="Hard Rock"', ["third"]), + ('genres:=~"hard rock"', ["third"]), + ("genres:=~rock", ["first", "second"]), + ('genres:="hard rock"', []), ("popebear", []), ("pope:bear", []), ("singleton:true", ["third"]), @@ -243,13 +243,7 @@ class TestGet: class TestMatch: @pytest.fixture(scope="class") def item(self): - return _common.item( - album="the album", - disc=6, - genre="the genre", - year=1, - bitrate=128000, - ) + return _common.item(album="the album", disc=6, year=1, bitrate=128000) @pytest.mark.parametrize( "q, should_match", @@ -260,9 +254,9 @@ class TestMatch: (SubstringQuery("album", "album"), True), (SubstringQuery("album", "ablum"), False), (SubstringQuery("disc", "6"), True), - (StringQuery("genre", "the genre"), True), - (StringQuery("genre", "THE GENRE"), True), - (StringQuery("genre", "genre"), False), + (StringQuery("album", "the album"), True), + (StringQuery("album", "THE ALBUM"), True), + (StringQuery("album", "album"), False), (NumericQuery("year", "1"), True), (NumericQuery("year", "10"), False), (NumericQuery("bitrate", "100000..200000"), True), diff --git a/test/test_sort.py b/test/test_sort.py index 460aa07b8..d7d651de5 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -33,7 +33,7 @@ class DummyDataTestCase(BeetsTestCase): albums = [ Album( album="Album A", - genre="Rock", + genres=["Rock"], year=2001, flex1="Flex1-1", flex2="Flex2-A", @@ -41,7 +41,7 @@ class DummyDataTestCase(BeetsTestCase): ), Album( album="Album B", - genre="Rock", + genres=["Rock"], year=2001, flex1="Flex1-2", flex2="Flex2-A", @@ -49,7 +49,7 @@ class DummyDataTestCase(BeetsTestCase): ), Album( album="Album C", - genre="Jazz", + genres=["Jazz"], year=2005, flex1="Flex1-1", flex2="Flex2-B", @@ -236,19 +236,19 @@ class SortAlbumFixedFieldTest(DummyDataTestCase): def test_sort_two_field_asc(self): q = "" - s1 = dbcore.query.FixedFieldSort("genre", True) + s1 = dbcore.query.FixedFieldSort("genres", True) s2 = dbcore.query.FixedFieldSort("album", True) sort = dbcore.query.MultipleSort() sort.add_sort(s1) sort.add_sort(s2) results = self.lib.albums(q, sort) - assert results[0]["genre"] <= results[1]["genre"] - assert results[1]["genre"] <= results[2]["genre"] - assert results[1]["genre"] == "Rock" - assert results[2]["genre"] == "Rock" + assert results[0]["genres"] <= results[1]["genres"] + assert results[1]["genres"] <= results[2]["genres"] + assert results[1]["genres"] == ["Rock"] + assert results[2]["genres"] == ["Rock"] assert results[1]["album"] <= results[2]["album"] # same thing with query string - q = "genre+ album+" + q = "genres+ album+" results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): assert r1.id == r2.id @@ -388,7 +388,7 @@ class CaseSensitivityTest(DummyDataTestCase): album = Album( album="album", - genre="alternative", + genres=["alternative"], year="2001", flex1="flex1", flex2="flex2-A", diff --git a/test/ui/commands/test_list.py b/test/ui/commands/test_list.py index 372d75410..0828980ca 100644 --- a/test/ui/commands/test_list.py +++ b/test/ui/commands/test_list.py @@ -63,6 +63,6 @@ class ListTest(IOMixin, BeetsTestCase): assert "the artist - the album - 0001" == stdout.strip() def test_list_album_format(self): - stdout = self._run_list(album=True, fmt="$genre") + stdout = self._run_list(album=True, fmt="$genres") assert "the genre" in stdout assert "the album" not in stdout diff --git a/test/ui/commands/test_update.py b/test/ui/commands/test_update.py index 3fb687418..937ded10c 100644 --- a/test/ui/commands/test_update.py +++ b/test/ui/commands/test_update.py @@ -103,22 +103,22 @@ class UpdateTest(IOMixin, BeetsTestCase): def test_selective_modified_metadata_moved(self): mf = MediaFile(syspath(self.i.path)) mf.title = "differentTitle" - mf.genre = "differentGenre" + mf.genres = ["differentGenre"] mf.save() self._update(move=True, fields=["title"]) item = self.lib.items().get() assert b"differentTitle" in item.path - assert item.genre != "differentGenre" + assert item.genres != ["differentGenre"] def test_selective_modified_metadata_not_moved(self): mf = MediaFile(syspath(self.i.path)) mf.title = "differentTitle" - mf.genre = "differentGenre" + mf.genres = ["differentGenre"] mf.save() self._update(move=False, fields=["title"]) item = self.lib.items().get() assert b"differentTitle" not in item.path - assert item.genre != "differentGenre" + assert item.genres != ["differentGenre"] def test_modified_album_metadata_moved(self): mf = MediaFile(syspath(self.i.path)) @@ -141,22 +141,22 @@ class UpdateTest(IOMixin, BeetsTestCase): def test_selective_modified_album_metadata_moved(self): mf = MediaFile(syspath(self.i.path)) mf.album = "differentAlbum" - mf.genre = "differentGenre" + mf.genres = ["differentGenre"] mf.save() self._update(move=True, fields=["album"]) item = self.lib.items().get() assert b"differentAlbum" in item.path - assert item.genre != "differentGenre" + assert item.genres != ["differentGenre"] def test_selective_modified_album_metadata_not_moved(self): mf = MediaFile(syspath(self.i.path)) mf.album = "differentAlbum" - mf.genre = "differentGenre" + mf.genres = ["differentGenre"] mf.save() - self._update(move=True, fields=["genre"]) + self._update(move=True, fields=["genres"]) item = self.lib.items().get() assert b"differentAlbum" not in item.path - assert item.genre == "differentGenre" + assert item.genres == ["differentGenre"] def test_mtime_match_skips_update(self): mf = MediaFile(syspath(self.i.path)) diff --git a/test/ui/test_field_diff.py b/test/ui/test_field_diff.py index d42e55a93..24bac0123 100644 --- a/test/ui/test_field_diff.py +++ b/test/ui/test_field_diff.py @@ -34,8 +34,8 @@ class TestFieldDiff: p({"title": "foo"}, {"title": "bar"}, "title", f"title: {diff_fmt('foo', 'bar')}", id="string_full_replace"), p({"title": "prefix foo"}, {"title": "prefix bar"}, "title", "title: prefix [text_diff_removed]foo[/] -> prefix [text_diff_added]bar[/]", id="string_partial_change"), p({"year": 2000}, {"year": 2001}, "year", f"year: {diff_fmt('2000', '2001')}", id="int_changed"), - p({}, {"genre": "Rock"}, "genre", "genre: -> [text_diff_added]Rock[/]", id="field_added"), - p({"genre": "Rock"}, {}, "genre", "genre: [text_diff_removed]Rock[/] -> ", id="field_removed"), + p({}, {"artist": "Artist"}, "artist", "artist: -> [text_diff_added]Artist[/]", id="field_added"), + p({"artist": "Artist"}, {}, "artist", "artist: [text_diff_removed]Artist[/] -> ", id="field_removed"), p({"track": 1}, {"track": 2}, "track", f"track: {diff_fmt('01', '02')}", id="formatted_value_changed"), p({"mb_trackid": None}, {"mb_trackid": "1234"}, "mb_trackid", "mb_trackid: -> [text_diff_added]1234[/]", id="none_to_value"), p({}, {"new_flex": "foo"}, "new_flex", "[text_diff_added]new_flex: foo[/]", id="flex_field_added"), From 52375472e83338bf7a0333fa2ca18fe1eed95265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 15 Feb 2026 13:17:26 +0000 Subject: [PATCH 25/31] Replace genre: with genres: in docs --- docs/plugins/fish.rst | 4 ++-- docs/plugins/ihate.rst | 8 ++++---- docs/plugins/zero.rst | 1 - 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/plugins/fish.rst b/docs/plugins/fish.rst index c1ae4f990..a26b06458 100644 --- a/docs/plugins/fish.rst +++ b/docs/plugins/fish.rst @@ -28,7 +28,7 @@ option flags available to you, which also applies to subcommands such as ``beet import -``. If you type ``beet ls`` followed by a space and then the and the ``TAB`` key, you will see a list of all the album/track fields that can be used in beets queries. For example, typing ``beet ls ge`` will complete to -``genre:`` and leave you ready to type the rest of your query. +``genres:`` and leave you ready to type the rest of your query. Options ------- @@ -42,7 +42,7 @@ commands and option flags. If you want generated completions to also contain album/track field *values* for the items in your library, you can use the ``-e`` or ``--extravalues`` option. For example: ``beet fish -e genre`` or ``beet fish -e genre -e albumartist`` In -the latter case, subsequently typing ``beet list genre: `` will display a +the latter case, subsequently typing ``beet list genres: `` will display a list of all the genres in your library and ``beet list albumartist: `` will show a list of the album artists in your library. Keep in mind that all of these values will be put into the generated completions file, so use this option with diff --git a/docs/plugins/ihate.rst b/docs/plugins/ihate.rst index 47e679dbd..6bb76d796 100644 --- a/docs/plugins/ihate.rst +++ b/docs/plugins/ihate.rst @@ -26,12 +26,12 @@ Here's an example: ihate: warn: - artist:rnb - - genre:soul + - genres:soul # Only warn about tribute albums in rock genre. - - genre:rock album:tribute + - genres:rock album:tribute skip: - - genre::russian\srock - - genre:polka + - genres::russian\srock + - genres:polka - artist:manowar - album:christmas diff --git a/docs/plugins/zero.rst b/docs/plugins/zero.rst index bf134e664..914e28faf 100644 --- a/docs/plugins/zero.rst +++ b/docs/plugins/zero.rst @@ -45,7 +45,6 @@ For example: zero: fields: month day genre genres comments comments: [EAC, LAME, from.+collection, 'ripped by'] - genre: [rnb, 'power metal'] genres: [rnb, 'power metal'] update_database: true From 6f886682eac28ed0046082c52a507df638e48014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 15 Feb 2026 13:38:53 +0000 Subject: [PATCH 26/31] Update changelog note --- docs/changelog.rst | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 290f63168..7d59a937a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -14,31 +14,36 @@ New features - Add native support for multiple genres per album/track. The ``genres`` field now stores genres as a list and is written to files as multiple individual - genre tags (e.g., separate GENRE tags for FLAC/MP3). The single ``genre`` - field is automatically synchronized to contain the first genre from the list - for backward compatibility. The :doc:`plugins/musicbrainz`, - :doc:`plugins/beatport`, and :doc:`plugins/lastgenre` plugins have been - updated to populate the ``genres`` field as a list. + genre tags (e.g., separate GENRE tags for FLAC/MP3). The + :doc:`plugins/musicbrainz`, :doc:`plugins/beatport`, :doc:`plugins/discogs` + and :doc:`plugins/lastgenre` plugins have been updated to populate the + ``genres`` field as a list. **Migration**: Existing libraries with comma-separated, semicolon-separated, or slash-separated genre strings (e.g., ``"Rock, Alternative, Indie"``) are automatically migrated to the ``genres`` list when you first run beets after upgrading. The migration runs once when the database schema is updated, - splitting genre strings and writing the changes to both the database and media - files. No manual action or ``mbsync`` is required. + splitting genre strings and writing the changes to the database. The updated + ``genres`` values will be written to media files the next time you run a + command that writes tags (such as ``beet write`` or during import). No manual + action or ``mbsync`` is required. .. Bug fixes ~~~~~~~~~ -.. - For plugin developers - ~~~~~~~~~~~~~~~~~~~~~ +For plugin developers +~~~~~~~~~~~~~~~~~~~~~ + +- If you maintain a metadata source plugin that populates the ``genre`` field, + please update it to populate a list of ``genres`` instead. You will see a + deprecation warning for now, but support for populating the single ``genre`` + field will be removed in version ``3.0.0``. Other changes ~~~~~~~~~~~~~ -- :ref:`modify-cmd`: Use the following separator to delimite multiple field +- :ref:`modify-cmd`: Use the following separator to delimit multiple field values: |semicolon_space|. For example ``beet modify albumtypes="album; ep"``. Previously, ``\␀`` was used as a separator. This applies to fields such as ``artists``, ``albumtypes`` etc. @@ -46,10 +51,10 @@ Other changes - :doc:`plugins/edit`: Editing multi-valued fields now behaves more naturally, with list values handled directly to make metadata edits smoother and more predictable. -- :doc:`plugins/lastgenre`: The ``separator`` configuration option is - deprecated. Genres are now stored as a list in the ``genres`` field and - written to files as individual genre tags. The separator option has no effect - and will be removed in a future version. +- :doc:`plugins/lastgenre`: The ``separator`` configuration option is removed. + Since genres are now stored as a list in the ``genres`` field and written to + files as individual genre tags, this option has no effect and has been + removed. 2.6.2 (February 22, 2026) ------------------------- From 67cf15b0bd7bb3ec1114c2a953af44adc34e613e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 15 Feb 2026 13:39:04 +0000 Subject: [PATCH 27/31] Remove lastgenre separator config --- beetsplug/lastgenre/__init__.py | 1 - docs/plugins/lastgenre.rst | 10 ---------- 2 files changed, 11 deletions(-) diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index bfa55ba90..41927a87c 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -111,7 +111,6 @@ class LastGenrePlugin(plugins.BeetsPlugin): "force": False, "keep_existing": False, "auto": True, - "separator": ", ", "prefer_specific": False, "title_case": True, "pretend": False, diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst index b677b001e..fa68ce9db 100644 --- a/docs/plugins/lastgenre.rst +++ b/docs/plugins/lastgenre.rst @@ -191,16 +191,6 @@ file. The available options are: Default: ``no``. - **source**: Which entity to look up in Last.fm. Can be either ``artist``, ``album`` or ``track``. Default: ``album``. -- **separator**: - - .. deprecated:: 2.6 - - The ``separator`` option is deprecated. Genres are now stored as a list in - the ``genres`` field and written to files as individual genre tags. This - option has no effect and will be removed in a future version. - - Default: ``', '``. - - **whitelist**: The filename of a custom genre list, ``yes`` to use the internal whitelist, or ``no`` to consider all genres valid. Default: ``yes``. - **title_case**: Convert the new tags to TitleCase before saving. Default: From 62e232983aa714517392e05feaecd20cd550b129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 22 Feb 2026 12:42:19 +0000 Subject: [PATCH 28/31] Document ordering of the genre split separator --- beets/autotag/hooks.py | 2 +- beets/library/migrations.py | 2 +- docs/changelog.rst | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 617d5051a..63ee52267 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -87,7 +87,7 @@ class Info(AttrDict[Any]): ) if not genres: try: - sep = next(s for s in [", ", "; ", " / "] if s in genre) + sep = next(s for s in ["; ", ", ", " / "] if s in genre) except StopIteration: genres = [genre] else: diff --git a/beets/library/migrations.py b/beets/library/migrations.py index 16f4c6761..c061ddfc5 100644 --- a/beets/library/migrations.py +++ b/beets/library/migrations.py @@ -37,7 +37,7 @@ class MultiGenreFieldMigration(Migration): with suppress(ConfigError): separators.append(beets.config["lastgenre"]["separator"].as_str()) - separators.extend([", ", "; ", " / "]) + separators.extend(["; ", ", ", " / "]) return unique_list(filter(None, separators)) @contextmanager diff --git a/docs/changelog.rst b/docs/changelog.rst index 7d59a937a..6cd8d7623 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -28,6 +28,14 @@ New features command that writes tags (such as ``beet write`` or during import). No manual action or ``mbsync`` is required. + The ``genre`` field is split by the first separator found in the string, in + the following order of precedence: + + 1. :doc:`plugins/lastgenre` ``separator`` configuration + 2. Semicolon followed by a space + 3. Comma followed by a space + 4. Slash wrapped by spaces + .. Bug fixes ~~~~~~~~~ From 2c63fe77ce07f1b3483df06d36a937934428326c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 22 Feb 2026 16:22:03 +0000 Subject: [PATCH 29/31] Remove test case indices from test_lastgenre.py --- test/plugins/test_lastgenre.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 5dcf2c165..55524d3fc 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -206,7 +206,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): @pytest.mark.parametrize( "config_values, item_genre, mock_genres, expected_result", [ - # 0 - force and keep whitelisted + # force and keep whitelisted ( { "force": True, @@ -223,7 +223,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): }, (["Blues", "Jazz"], "keep + album, whitelist"), ), - # 1 - force and keep whitelisted, unknown original + # force and keep whitelisted, unknown original ( { "force": True, @@ -239,7 +239,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): }, (["Blues", "Jazz"], "keep + album, whitelist"), ), - # 2 - force and keep whitelisted on empty tag + # force and keep whitelisted on empty tag ( { "force": True, @@ -255,7 +255,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): }, (["Jazz"], "album, whitelist"), ), - # 3 force and keep, artist configured + # force and keep, artist configured ( { "force": True, @@ -272,7 +272,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): }, (["Blues", "Pop"], "keep + artist, whitelist"), ), - # 4 - don't force, disabled whitelist + # don't force, disabled whitelist ( { "force": False, @@ -288,7 +288,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): }, (["any genre"], "keep any, no-force"), ), - # 5 - don't force and empty is regular last.fm fetch; no whitelist too + # don't force and empty is regular last.fm fetch; no whitelist too ( { "force": False, @@ -304,7 +304,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): }, (["Jazzin"], "album, any"), ), - # 6 - fallback to next stages until found + # fallback to next stages until found ( { "force": True, @@ -322,7 +322,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): }, (["Unknown Genre", "Jazz"], "keep + artist, any"), ), - # 7 - Keep the original genre when force and keep_existing are on, and + # Keep the original genre when force and keep_existing are on, and # whitelist is disabled ( { @@ -342,7 +342,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): }, (["any existing"], "original fallback"), ), - # 7.1 - Keep the original genre when force and keep_existing are on, and + # Keep the original genre when force and keep_existing are on, and # whitelist is enabled, and genre is valid. ( { @@ -362,7 +362,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): }, (["Jazz"], "original fallback"), ), - # 7.2 - Return the configured fallback when force is on but + # Return the configured fallback when force is on but # keep_existing is not. ( { @@ -382,7 +382,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): }, (["fallback genre"], "fallback"), ), - # 8 - fallback to fallback if no original + # fallback to fallback if no original ( { "force": True, @@ -401,7 +401,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): }, (["fallback genre"], "fallback"), ), - # 9 - limit a lot of results + # limit a lot of results ( { "force": True, @@ -421,7 +421,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "keep + album, whitelist", ), ), - # 10 - fallback to next stage (artist) if no allowed original present + # fallback to next stage (artist) if no allowed original present # and no album genre were fetched. ( { @@ -441,7 +441,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): }, (["Jazz"], "keep + artist, whitelist"), ), - # 11 - canonicalization transforms non-whitelisted genres to canonical forms + # canonicalization transforms non-whitelisted genres to canonical forms # # "Acid Techno" is not in the default whitelist, thus gets resolved "up" in the # tree to "Techno" and "Electronic". @@ -461,7 +461,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): }, (["Techno", "Electronic"], "album, whitelist"), ), - # 12 - canonicalization transforms whitelisted genres to canonical forms and + # canonicalization transforms whitelisted genres to canonical forms and # includes originals # # "Detroit Techno" is in the default whitelist, thus it stays and and also gets @@ -493,7 +493,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "keep + album, whitelist", ), ), - # 13 - canonicalization transforms non-whitelisted original genres to canonical + # canonicalization transforms non-whitelisted original genres to canonical # forms and deduplication works. # # "Cosmic Disco" is not in the default whitelist, thus gets resolved "up" in the @@ -518,7 +518,7 @@ class LastGenrePluginTest(IOMixin, PluginTestCase): "keep + album, whitelist", ), ), - # 16 - canonicalization transforms non-whitelisted original genres to canonical + # canonicalization transforms non-whitelisted original genres to canonical # forms and deduplication works, **even** when no new genres are found online. # # "Cosmic Disco" is not in the default whitelist, thus gets resolved "up" in the From 10d13992e66d9eeb238deafc2c0a4a2576e25522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 23 Feb 2026 04:51:41 +0000 Subject: [PATCH 30/31] Dedupe genres parsing in beatport --- beetsplug/beatport.py | 14 +++++--------- test/plugins/test_beatport.py | 2 +- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 3368825b8..aa0693541 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -234,9 +234,11 @@ class BeatportObject: ) if "artists" in data: self.artists = [(x["id"], str(x["name"])) for x in data["artists"]] - if "genres" in data: - genre_list = [str(x["name"]) for x in data["genres"]] - self.genres = unique_list(genre_list) + + self.genres = unique_list( + x["name"] + for x in (*data.get("subGenres", []), *data.get("genres", [])) + ) def artists_str(self) -> str | None: if self.artists is not None: @@ -305,12 +307,6 @@ class BeatportTrack(BeatportObject): self.bpm = data.get("bpm") self.initial_key = str((data.get("key") or {}).get("shortName")) - # Extract genres list from subGenres or genres - self.genres = unique_list( - str(x.get("name")) - for x in data.get("subGenres") or data.get("genres") or [] - ) - class BeatportPlugin(MetadataSourcePlugin): _client: BeatportClient | None = None diff --git a/test/plugins/test_beatport.py b/test/plugins/test_beatport.py index 442f80037..96386d8b6 100644 --- a/test/plugins/test_beatport.py +++ b/test/plugins/test_beatport.py @@ -474,7 +474,7 @@ class BeatportTest(BeetsTestCase): item.year = 2016 item.comp = False item.label_name = "Gravitas Recordings" - item.genres = ["Glitch Hop"] + item.genres = ["Glitch Hop", "Breaks"] item.year = 2016 item.month = 4 item.day = 11 From a540a8174a57e3f719dfeda536c394a06b326dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 27 Feb 2026 17:52:50 +0000 Subject: [PATCH 31/31] Clarify tests --- test/test_importer.py | 26 +++++++++++++------------- test/test_library.py | 25 ++++++++----------------- 2 files changed, 21 insertions(+), 30 deletions(-) diff --git a/test/test_importer.py b/test/test_importer.py index a560ca5af..a7d57dbb2 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -304,15 +304,15 @@ class ImportSingletonTest(AutotagImportTestCase): assert len(self.lib.albums()) == 2 def test_set_fields(self): - genre = "\U0001f3b7 Jazz" + genres = ["\U0001f3b7 Jazz", "Rock"] collection = "To Listen" disc = 0 config["import"]["set_fields"] = { + "genres": "; ".join(genres), "collection": collection, - "genres": genre, - "title": "$title - formatted", "disc": disc, + "title": "$title - formatted", } # As-is item import. @@ -322,7 +322,7 @@ class ImportSingletonTest(AutotagImportTestCase): for item in self.lib.items(): item.load() # TODO: Not sure this is necessary. - assert item.genres == [genre] + assert item.genres == genres assert item.collection == collection assert item.title == "Tag Track 1 - formatted" assert item.disc == disc @@ -337,7 +337,7 @@ class ImportSingletonTest(AutotagImportTestCase): for item in self.lib.items(): item.load() - assert item.genres == [genre] + assert item.genres == genres assert item.collection == collection assert item.title == "Applied Track 1 - formatted" assert item.disc == disc @@ -464,17 +464,17 @@ class ImportTest(PathsMixin, AutotagImportTestCase): self.lib.items().get().data_source def test_set_fields(self): - genre = "\U0001f3b7 Jazz" + genres = ["\U0001f3b7 Jazz", "Rock"] collection = "To Listen" - comments = "managed by beets" disc = 0 + comments = "managed by beets" config["import"]["set_fields"] = { - "genres": genre, + "genres": "; ".join(genres), "collection": collection, + "disc": disc, "comments": comments, "album": "$album - formatted", - "disc": disc, } # As-is album import. @@ -483,10 +483,10 @@ class ImportTest(PathsMixin, AutotagImportTestCase): self.importer.run() for album in self.lib.albums(): - assert album.genres == [genre] + assert album.genres == genres assert album.comments == comments for item in album.items(): - assert item.get("genres", with_album=False) == [genre] + assert item.get("genres", with_album=False) == genres assert item.get("collection", with_album=False) == collection assert item.get("comments", with_album=False) == comments assert ( @@ -504,10 +504,10 @@ class ImportTest(PathsMixin, AutotagImportTestCase): self.importer.run() for album in self.lib.albums(): - assert album.genres == [genre] + assert album.genres == genres assert album.comments == comments for item in album.items(): - assert item.get("genres", with_album=False) == [genre] + assert item.get("genres", with_album=False) == genres assert item.get("collection", with_album=False) == collection assert item.get("comments", with_album=False) == comments assert ( diff --git a/test/test_library.py b/test/test_library.py index de7ff693b..5af6f76d8 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -56,27 +56,18 @@ class LoadTest(ItemInDBTestCase): class StoreTest(ItemInDBTestCase): def test_store_changes_database_value(self): - self.i.year = 1987 + new_year = 1987 + self.i.year = new_year self.i.store() - new_year = ( - self.lib._connection() - .execute("select year from items where title = ?", (self.i.title,)) - .fetchone()["year"] - ) - assert new_year == 1987 + + assert self.lib.get_item(self.i.id).year == new_year def test_store_only_writes_dirty_fields(self): - original_genres = self.i.genres - self.i._values_fixed["genres"] = ["beatboxing"] # change w/o dirtying + new_year = 1987 + self.i._values_fixed["year"] = new_year # change w/o dirtying self.i.store() - new_genre = ( - self.lib._connection() - .execute( - "select genres from items where title = ?", (self.i.title,) - ) - .fetchone()["genres"] - ) - assert [new_genre] == original_genres + + assert self.lib.get_item(self.i.id).year != new_year def test_store_clears_dirty_flags(self): self.i.composer = "tvp"