Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
306211a
Add native support for multiple genres per album/track
jfot-cpu Dec 6, 2025
1a158b9
remove noisy comment from test/plugins/test_beatport.py
dunkla Dec 28, 2025
fc7988d
shorte test description in test/plugins/test_lastgenre.py
dunkla Dec 28, 2025
b90cd5d
better function description in beetsplug/lastgenre/__init__.py
dunkla Dec 28, 2025
4fec632
simplify return logic in beetsplug/lastgenre/__init__.py
dunkla Dec 28, 2025
c735ffb
simplify genre unpacking in beetsplug/lastgenre/__init__.py
dunkla Dec 28, 2025
d565524
simplify check for fallback in beetsplug/lastgenre/__init__.py
dunkla Dec 28, 2025
9fc90dd
Implement automatic database-level genre migration
dunkla Dec 28, 2025
5e3e3cb
Simplify MusicBrainz genres assignment
dunkla Dec 28, 2025
9fbf0ed
Remove manual migrate command
dunkla Dec 28, 2025
7f15e1b
Fix lastgenre migration separator logic (ref https://github.com/beetb…
dunkla Jan 10, 2026
c67dea2
Use compact generator expression in Beatport (ref https://github.com/…
dunkla Jan 10, 2026
ea67c8e
Remove conditional logic from lastgenre tests (ref https://github.com…
dunkla Jan 10, 2026
c0f3800
Remove noisy comments from beatport tests (ref https://github.com/bee…
dunkla Jan 10, 2026
e68f1e6
Update lastgenre docstring and remove misleading comment (ref https:/…
dunkla Jan 10, 2026
de215f5
Merge branch 'master' into claude/add-multiple-genres-01AKN5cZkyhLLwf…
dunkla Jan 11, 2026
8832912
Fix import sorting in lastgenre (I001)
dunkla Jan 11, 2026
3fd1de6
Fix mypy incompatible return type in lastgenre
dunkla Jan 11, 2026
25f3d6e
Remove redundant lastgenre separator checks
dunkla Jan 18, 2026
69fed7d
Merge branch 'master' into claude/add-multiple-genres-01AKN5cZkyhLLwf…
dunkla Jan 18, 2026
68445ce
fix lint/format
dunkla Jan 18, 2026
fce33f8
fix lint/format
dunkla Jan 18, 2026
9020612
clearer migration note in changelog
dunkla Jan 18, 2026
0266b6c
apply docstrfmt formatting to changelog
dunkla Jan 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions beets/autotag/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ def ensure_first_value(single_field: str, list_field: str) -> 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")
Expand Down
25 changes: 24 additions & 1 deletion beets/autotag/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from __future__ import annotations

import warnings
from copy import deepcopy
from dataclasses import dataclass
from functools import cached_property
Expand Down Expand Up @@ -76,9 +77,30 @@ def __init__(
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:
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
Expand All @@ -90,7 +112,8 @@ def __init__(
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)

Expand Down
108 changes: 106 additions & 2 deletions beets/library/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@
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):
Expand Down Expand Up @@ -142,3 +147,102 @@ def get_album(self, item_or_id: Item | int) -> Album | None:
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:
# Read user's configured lastgenre separator (optional)
user_sep = (
beets.config["lastgenre"]["separator"].get(str)
if "lastgenre" in beets.config
else None
)

# Try user's separator first, then common defaults
separators = ([user_sep] if user_sep else []) + [
", ",
"; ",
" / ",
]

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"
)
3 changes: 3 additions & 0 deletions beets/library/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,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,
Expand Down Expand Up @@ -297,6 +298,7 @@ def _types(cls) -> dict[str, types.Type]:
"albumartists_credit",
"album",
"genre",
"genres",
"style",
"discogs_albumid",
"discogs_artistid",
Expand Down Expand Up @@ -645,6 +647,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,
Expand Down
21 changes: 10 additions & 11 deletions beetsplug/beatport.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
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
Expand Down Expand Up @@ -234,7 +235,8 @@ def __init__(self, data: JSONDict):
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"]]
self.genres = unique_list(genre_list)

def artists_str(self) -> str | None:
if self.artists is not None:
Expand All @@ -253,7 +255,6 @@ class BeatportRelease(BeatportObject):
label_name: str | None
category: str | None
url: str | None
genre: str | None

tracks: list[BeatportTrack] | None = None

Expand All @@ -263,7 +264,6 @@ def __init__(self, data: JSONDict):
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 = (
Expand All @@ -285,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)
Expand All @@ -306,11 +305,11 @@ def __init__(self, data: JSONDict):
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.
if data.get("subGenres"):
self.genre = str(data["subGenres"][0].get("name"))
elif data.get("genres"):
self.genre = str(data["genres"][0].get("name"))
# 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):
Expand Down Expand Up @@ -483,7 +482,7 @@ def _get_album_info(self, release: BeatportRelease) -> AlbumInfo:
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,
day=release_date.day if release_date else None,
Expand All @@ -508,7 +507,7 @@ def _get_track_info(self, track: BeatportTrack) -> TrackInfo:
data_url=track.url,
bpm=track.bpm,
initial_key=track.initial_key,
genre=track.genre,
genres=track.genres,
)

def _get_artist(self, artists):
Expand Down
Loading
Loading