Skip to content

Commit 328f3b2

Browse files
committed
Implement automatic database-level genre migration
- Override Library._make_table() to detect when genres column is added - Automatically migrate comma-separated genres to list format on first run - Try multiple separators: ", ", "; ", " / " for legacy compatibility - Show progress every 100 items to avoid CLI spam for large libraries - Write changes to both database and media files during migration - Remove manual migrate command (migration is now automatic and mandatory) - Simplify correct_list_fields() to only sync genre ↔ genres fields - Update all tests for list-based genres (lastgenre, musicbrainz, autotag) - Update documentation to reflect automatic migration behavior Addresses maintainer feedback on PR beetbox#6169. Migration runs once when the database schema is updated, ensuring all users transition to multi-value genres seamlessly without manual intervention.
1 parent de4ca65 commit 328f3b2

File tree

8 files changed

+443
-572
lines changed

8 files changed

+443
-572
lines changed

beets/autotag/__init__.py

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -166,37 +166,7 @@ def ensure_first_value(single_field: str, list_field: str) -> None:
166166
elif list_val:
167167
setattr(m, single_field, list_val[0])
168168

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

202172
if hasattr(m, "mb_artistids"):

beets/library/library.py

Lines changed: 102 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@
55
import platformdirs
66

77
import beets
8-
from beets import dbcore
8+
from beets import dbcore, logging, ui
9+
from beets.autotag import correct_list_fields
910
from beets.util import normpath
1011

1112
from .models import Album, Item
1213
from .queries import PF_KEY_DEFAULT, parse_query_parts, parse_query_string
1314

1415
if TYPE_CHECKING:
15-
from beets.dbcore import Results
16+
from collections.abc import Mapping
17+
18+
from beets.dbcore import Results, types
19+
20+
log = logging.getLogger("beets")
1621

1722

1823
class Library(dbcore.Database):
@@ -125,20 +130,109 @@ def items(self, query=None, sort=None) -> Results[Item]:
125130
return self._fetch(Item, query, sort or self.get_default_item_sort())
126131

127132
# Convenience accessors.
128-
def get_item(self, id_: int) -> Item | None:
133+
134+
def get_item(self, id):
129135
"""Fetch a :class:`Item` by its ID.
130136
131137
Return `None` if no match is found.
132138
"""
133-
return self._get(Item, id_)
139+
return self._get(Item, id)
134140

135-
def get_album(self, item_or_id: Item | int) -> Album | None:
141+
def get_album(self, item_or_id):
136142
"""Given an album ID or an item associated with an album, return
137143
a :class:`Album` object for the album.
138144
139145
If no such album exists, return `None`.
140146
"""
141-
album_id = (
142-
item_or_id if isinstance(item_or_id, int) else item_or_id.album_id
147+
if isinstance(item_or_id, int):
148+
album_id = item_or_id
149+
else:
150+
album_id = item_or_id.album_id
151+
if album_id is None:
152+
return None
153+
return self._get(Album, album_id)
154+
155+
# Database schema migration.
156+
157+
def _make_table(self, table: str, fields: Mapping[str, types.Type]):
158+
"""Set up the schema of the database, and migrate genres if needed."""
159+
with self.transaction() as tx:
160+
rows = tx.query(f"PRAGMA table_info({table})")
161+
current_fields = {row[1] for row in rows}
162+
field_names = set(fields.keys())
163+
164+
# Check if genres column is being added to items table
165+
genres_being_added = (
166+
table == "items"
167+
and "genres" in field_names
168+
and "genres" not in current_fields
169+
and "genre" in current_fields
170+
)
171+
172+
# Call parent to create/update table
173+
super()._make_table(table, fields)
174+
175+
# Migrate genre to genres if genres column was just added
176+
if genres_being_added:
177+
self._migrate_genre_to_genres()
178+
179+
def _migrate_genre_to_genres(self):
180+
"""Migrate comma-separated genre strings to genres list.
181+
182+
This migration runs automatically when the genres column is first
183+
created in the database. It splits comma-separated genre values
184+
and writes the changes to both the database and media files.
185+
"""
186+
items = list(self.items())
187+
migrated_count = 0
188+
total_items = len(items)
189+
190+
if total_items == 0:
191+
return
192+
193+
ui.print_(f"Migrating genres for {total_items} items...")
194+
195+
for index, item in enumerate(items, 1):
196+
genre_val = item.genre or ""
197+
genres_val = item.genres or []
198+
199+
# Check if migration is needed
200+
needs_migration = False
201+
split_genres = []
202+
if not genres_val and genre_val:
203+
for separator in [", ", "; ", " / "]:
204+
if separator in genre_val:
205+
split_genres = [
206+
g.strip()
207+
for g in genre_val.split(separator)
208+
if g.strip()
209+
]
210+
if len(split_genres) > 1:
211+
needs_migration = True
212+
break
213+
214+
if needs_migration:
215+
migrated_count += 1
216+
# Show progress every 100 items
217+
if migrated_count % 100 == 0:
218+
ui.print_(
219+
f" Migrated {migrated_count} items "
220+
f"({index}/{total_items} processed)..."
221+
)
222+
# Migrate using the same logic as correct_list_fields
223+
correct_list_fields(item)
224+
item.store()
225+
# Write to media file
226+
try:
227+
item.try_write()
228+
except Exception as e:
229+
log.warning(
230+
"Could not write genres to {}: {}",
231+
item.path,
232+
e,
233+
)
234+
235+
ui.print_(
236+
f"Migration complete: {migrated_count} of {total_items} items "
237+
f"updated with comma-separated genres"
143238
)
144-
return self._get(Album, album_id) if album_id else None

beets/ui/commands/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
from .help import HelpCommand
2525
from .import_ import import_cmd
2626
from .list import list_cmd
27-
from .migrate import migrate_cmd
2827
from .modify import modify_cmd
2928
from .move import move_cmd
3029
from .remove import remove_cmd
@@ -53,7 +52,6 @@ def __getattr__(name: str):
5352
HelpCommand(),
5453
import_cmd,
5554
list_cmd,
56-
migrate_cmd,
5755
modify_cmd,
5856
move_cmd,
5957
remove_cmd,

beets/ui/commands/migrate.py

Lines changed: 0 additions & 98 deletions
This file was deleted.

0 commit comments

Comments
 (0)