Skip to content

Commit 22cdda7

Browse files
committed
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
1 parent 07445fd commit 22cdda7

File tree

13 files changed

+404
-65
lines changed

13 files changed

+404
-65
lines changed

beets/autotag/__init__.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,38 @@ def ensure_first_value(single_field: str, list_field: str) -> None:
172172
elif list_val:
173173
setattr(m, single_field, list_val[0])
174174

175+
def migrate_legacy_genres() -> None:
176+
"""Migrate comma-separated genre strings to genres list.
177+
178+
For users upgrading from previous versions, their genre field may
179+
contain comma-separated values (e.g., "Rock, Alternative, Indie").
180+
This migration splits those values into the genres list on first access,
181+
avoiding the need to reimport the entire library.
182+
"""
183+
genre_val = getattr(m, "genre", "")
184+
genres_val = getattr(m, "genres", [])
185+
186+
# Only migrate if genres list is empty and genre contains separators
187+
if not genres_val and genre_val:
188+
# Try common separators used by lastgenre and other tools
189+
for separator in [", ", "; ", " / "]:
190+
if separator in genre_val:
191+
# Split and clean the genre string
192+
split_genres = [
193+
g.strip()
194+
for g in genre_val.split(separator)
195+
if g.strip()
196+
]
197+
if len(split_genres) > 1:
198+
# Found a valid split - populate genres list
199+
setattr(m, "genres", split_genres)
200+
# Clear genre so ensure_first_value sets it correctly
201+
setattr(m, "genre", "")
202+
break
203+
175204
ensure_first_value("albumtype", "albumtypes")
205+
migrate_legacy_genres()
206+
ensure_first_value("genre", "genres")
176207

177208
if hasattr(m, "mb_artistids"):
178209
ensure_first_value("mb_artistid", "mb_artistids")

beets/autotag/hooks.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def __init__(
6868
data_source: str | None = None,
6969
data_url: str | None = None,
7070
genre: str | None = None,
71+
genres: list[str] | None = None,
7172
media: str | None = None,
7273
**kwargs,
7374
) -> None:
@@ -83,6 +84,7 @@ def __init__(
8384
self.data_source = data_source
8485
self.data_url = data_url
8586
self.genre = genre
87+
self.genres = genres or []
8688
self.media = media
8789
self.update(kwargs)
8890

beets/library/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ class Album(LibModel):
241241
"albumartists_credit": types.MULTI_VALUE_DSV,
242242
"album": types.STRING,
243243
"genre": types.STRING,
244+
"genres": types.MULTI_VALUE_DSV,
244245
"style": types.STRING,
245246
"discogs_albumid": types.INTEGER,
246247
"discogs_artistid": types.INTEGER,
@@ -297,6 +298,7 @@ def _types(cls) -> dict[str, types.Type]:
297298
"albumartists_credit",
298299
"album",
299300
"genre",
301+
"genres",
300302
"style",
301303
"discogs_albumid",
302304
"discogs_artistid",
@@ -643,6 +645,7 @@ class Item(LibModel):
643645
"albumartist_credit": types.STRING,
644646
"albumartists_credit": types.MULTI_VALUE_DSV,
645647
"genre": types.STRING,
648+
"genres": types.MULTI_VALUE_DSV,
646649
"style": types.STRING,
647650
"discogs_albumid": types.INTEGER,
648651
"discogs_artistid": types.INTEGER,

beets/ui/commands/__init__.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from .help import HelpCommand
2525
from .import_ import import_cmd
2626
from .list import list_cmd
27+
from .migrate import migrate_cmd
2728
from .modify import modify_cmd
2829
from .move import move_cmd
2930
from .remove import remove_cmd
@@ -54,12 +55,13 @@ def __getattr__(name: str):
5455
HelpCommand(),
5556
import_cmd,
5657
list_cmd,
57-
update_cmd,
58+
migrate_cmd,
59+
modify_cmd,
60+
move_cmd,
5861
remove_cmd,
5962
stats_cmd,
63+
update_cmd,
6064
version_cmd,
61-
modify_cmd,
62-
move_cmd,
6365
write_cmd,
6466
config_cmd,
6567
completion_cmd,

beets/ui/commands/migrate.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""The 'migrate' command: migrate library data for format changes."""
2+
3+
from beets import logging, ui
4+
from beets.autotag import correct_list_fields
5+
6+
# Global logger.
7+
log = logging.getLogger("beets")
8+
9+
10+
def migrate_genres(lib, pretend=False):
11+
"""Migrate comma-separated genre strings to genres list.
12+
13+
For users upgrading from previous versions, their genre field may
14+
contain comma-separated values (e.g., "Rock, Alternative, Indie").
15+
This command splits those values into the genres list, avoiding
16+
the need to reimport the entire library.
17+
"""
18+
items = lib.items()
19+
migrated_count = 0
20+
total_items = 0
21+
22+
ui.print_("Scanning library for items with comma-separated genres...")
23+
24+
for item in items:
25+
total_items += 1
26+
genre_val = item.genre or ""
27+
genres_val = item.genres or []
28+
29+
# Check if migration is needed
30+
needs_migration = False
31+
if not genres_val and genre_val:
32+
for separator in [", ", "; ", " / "]:
33+
if separator in genre_val:
34+
split_genres = [
35+
g.strip()
36+
for g in genre_val.split(separator)
37+
if g.strip()
38+
]
39+
if len(split_genres) > 1:
40+
needs_migration = True
41+
break
42+
43+
if needs_migration:
44+
migrated_count += 1
45+
old_genre = item.genre
46+
old_genres = item.genres or []
47+
48+
if pretend:
49+
# Just show what would change
50+
ui.print_(
51+
f" Would migrate: {item.artist} - {item.title}\n"
52+
f" genre: {old_genre!r} -> {split_genres[0]!r}\n"
53+
f" genres: {old_genres!r} -> {split_genres!r}"
54+
)
55+
else:
56+
# Actually migrate
57+
correct_list_fields(item)
58+
item.store()
59+
log.debug(
60+
"migrated: {} - {} ({} -> {})",
61+
item.artist,
62+
item.title,
63+
old_genre,
64+
item.genres,
65+
)
66+
67+
# Show summary
68+
if pretend:
69+
ui.print_(
70+
f"\nWould migrate {migrated_count} of {total_items} items "
71+
f"(run without --pretend to apply changes)"
72+
)
73+
else:
74+
ui.print_(
75+
f"\nMigrated {migrated_count} of {total_items} items with "
76+
f"comma-separated genres"
77+
)
78+
79+
80+
def migrate_func(lib, opts, args):
81+
"""Handle the migrate command."""
82+
if not args or args[0] == "genres":
83+
migrate_genres(lib, pretend=opts.pretend)
84+
else:
85+
raise ui.UserError(f"unknown migration target: {args[0]}")
86+
87+
88+
migrate_cmd = ui.Subcommand(
89+
"migrate", help="migrate library data for format changes"
90+
)
91+
migrate_cmd.parser.add_option(
92+
"-p",
93+
"--pretend",
94+
action="store_true",
95+
help="show what would be changed without applying",
96+
)
97+
migrate_cmd.parser.usage = "%prog migrate genres [options]"
98+
migrate_cmd.func = migrate_func

beetsplug/beatport.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,9 @@ def __init__(self, data: JSONDict):
234234
if "artists" in data:
235235
self.artists = [(x["id"], str(x["name"])) for x in data["artists"]]
236236
if "genres" in data:
237-
self.genres = [str(x["name"]) for x in data["genres"]]
237+
genre_list = [str(x["name"]) for x in data["genres"]]
238+
# Remove duplicates while preserving order
239+
self.genres = list(dict.fromkeys(genre_list))
238240

239241
def artists_str(self) -> str | None:
240242
if self.artists is not None:
@@ -306,11 +308,16 @@ def __init__(self, data: JSONDict):
306308
self.bpm = data.get("bpm")
307309
self.initial_key = str((data.get("key") or {}).get("shortName"))
308310

309-
# Use 'subgenre' and if not present, 'genre' as a fallback.
311+
# Extract genres list from subGenres or genres
310312
if data.get("subGenres"):
311-
self.genre = str(data["subGenres"][0].get("name"))
313+
genre_list = [str(x.get("name")) for x in data["subGenres"]]
312314
elif data.get("genres"):
313-
self.genre = str(data["genres"][0].get("name"))
315+
genre_list = [str(x.get("name")) for x in data["genres"]]
316+
else:
317+
genre_list = []
318+
319+
# Remove duplicates while preserving order
320+
self.genres = list(dict.fromkeys(genre_list))
314321

315322

316323
class BeatportPlugin(MetadataSourcePlugin):
@@ -484,6 +491,7 @@ def _get_album_info(self, release: BeatportRelease) -> AlbumInfo:
484491
data_source=self.data_source,
485492
data_url=release.url,
486493
genre=release.genre,
494+
genres=release.genres,
487495
year=release_date.year if release_date else None,
488496
month=release_date.month if release_date else None,
489497
day=release_date.day if release_date else None,
@@ -509,6 +517,7 @@ def _get_track_info(self, track: BeatportTrack) -> TrackInfo:
509517
bpm=track.bpm,
510518
initial_key=track.initial_key,
511519
genre=track.genre,
520+
genres=track.genres,
512521
)
513522

514523
def _get_artist(self, artists):

beetsplug/lastgenre/__init__.py

Lines changed: 30 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -322,26 +322,23 @@ def fetch_track_genre(self, obj):
322322

323323
# Main processing: _get_genre() and helpers.
324324

325-
def _format_and_stringify(self, tags: list[str]) -> str:
326-
"""Format to title_case if configured and return as delimited string."""
325+
def _format_genres(self, tags: list[str]) -> list[str]:
326+
"""Format to title_case if configured and return as list."""
327327
if self.config["title_case"]:
328-
formatted = [tag.title() for tag in tags]
328+
return [tag.title() for tag in tags]
329329
else:
330-
formatted = tags
331-
332-
return self.config["separator"].as_str().join(formatted)
330+
return tags
333331

334332
def _get_existing_genres(self, obj: LibModel) -> list[str]:
335333
"""Return a list of genres for this Item or Album. Empty string genres
336334
are removed."""
337-
separator = self.config["separator"].get()
338335
if isinstance(obj, library.Item):
339-
item_genre = obj.get("genre", with_album=False).split(separator)
336+
genres_list = obj.get("genres", with_album=False)
340337
else:
341-
item_genre = obj.get("genre").split(separator)
338+
genres_list = obj.get("genres")
342339

343340
# Filter out empty strings
344-
return [g for g in item_genre if g]
341+
return [g for g in genres_list if g] if genres_list else []
345342

346343
def _combine_resolve_and_log(
347344
self, old: list[str], new: list[str]
@@ -352,8 +349,8 @@ def _combine_resolve_and_log(
352349
combined = old + new
353350
return self._resolve_genres(combined)
354351

355-
def _get_genre(self, obj: LibModel) -> tuple[str | None, ...]:
356-
"""Get the final genre string for an Album or Item object.
352+
def _get_genre(self, obj: LibModel) -> tuple[list[str], str]:
353+
"""Get the final genre list for an Album or Item object.
357354
358355
`self.sources` specifies allowed genre sources. Starting with the first
359356
source in this tuple, the following stages run through until a genre is
@@ -363,9 +360,9 @@ def _get_genre(self, obj: LibModel) -> tuple[str | None, ...]:
363360
- artist, albumartist or "most popular track genre" (for VA-albums)
364361
- original fallback
365362
- configured fallback
366-
- None
363+
- empty list
367364
368-
A `(genre, label)` pair is returned, where `label` is a string used for
365+
A `(genres, label)` pair is returned, where `label` is a string used for
369366
logging. For example, "keep + artist, whitelist" indicates that existing
370367
genres were combined with new last.fm genres and whitelist filtering was
371368
applied, while "artist, any" means only new last.fm genres are included
@@ -382,7 +379,7 @@ def _try_resolve_stage(stage_label: str, keep_genres, new_genres):
382379
label = f"{stage_label}, {suffix}"
383380
if keep_genres:
384381
label = f"keep + {label}"
385-
return self._format_and_stringify(resolved_genres), label
382+
return self._format_genres(resolved_genres), label
386383
return None
387384

388385
keep_genres = []
@@ -391,10 +388,7 @@ def _try_resolve_stage(stage_label: str, keep_genres, new_genres):
391388

392389
if genres and not self.config["force"]:
393390
# Without force pre-populated tags are returned as-is.
394-
label = "keep any, no-force"
395-
if isinstance(obj, library.Item):
396-
return obj.get("genre", with_album=False), label
397-
return obj.get("genre"), label
391+
return genres, "keep any, no-force"
398392

399393
if self.config["force"]:
400394
# Force doesn't keep any unless keep_existing is set.
@@ -454,26 +448,33 @@ def _try_resolve_stage(stage_label: str, keep_genres, new_genres):
454448
return result
455449

456450
# Nothing found, leave original if configured and valid.
457-
if obj.genre and self.config["keep_existing"]:
458-
if not self.whitelist or self._is_valid(obj.genre.lower()):
459-
return obj.genre, "original fallback"
460-
461-
# Return fallback string.
451+
if genres and self.config["keep_existing"]:
452+
# Check if at least one genre is valid
453+
valid_genres = [
454+
g
455+
for g in genres
456+
if not self.whitelist or self._is_valid(g.lower())
457+
]
458+
if valid_genres:
459+
return valid_genres, "original fallback"
460+
461+
# Return fallback as a list.
462462
if fallback := self.config["fallback"].get():
463-
return fallback, "fallback"
463+
return [fallback], "fallback"
464464

465465
# No fallback configured.
466-
return None, "fallback unconfigured"
466+
return [], "fallback unconfigured"
467467

468468
# Beets plugin hooks and CLI.
469469

470470
def _fetch_and_log_genre(self, obj: LibModel) -> None:
471471
"""Fetch genre and log it."""
472472
self._log.info(str(obj))
473-
obj.genre, label = self._get_genre(obj)
474-
self._log.debug("Resolved ({}): {}", label, obj.genre)
473+
genres_list, label = self._get_genre(obj)
474+
obj.genres = genres_list
475+
self._log.debug("Resolved ({}): {}", label, genres_list)
475476

476-
ui.show_model_changes(obj, fields=["genre"], print_obj=False)
477+
ui.show_model_changes(obj, fields=["genres"], print_obj=False)
477478

478479
@singledispatchmethod
479480
def _process(self, obj: LibModel, write: bool) -> None:

beetsplug/musicbrainz.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -736,10 +736,11 @@ def album_info(self, release: JSONDict) -> beets.autotag.hooks.AlbumInfo:
736736
for source in sources:
737737
for genreitem in source:
738738
genres[genreitem["name"]] += int(genreitem["count"])
739-
info.genre = "; ".join(
739+
genre_list = [
740740
genre
741741
for genre, _count in sorted(genres.items(), key=lambda g: -g[1])
742-
)
742+
]
743+
info.genres = genre_list
743744

744745
# We might find links to external sources (Discogs, Bandcamp, ...)
745746
external_ids = self.config["external_ids"].get()

0 commit comments

Comments
 (0)