Add multiple genres (#6367)

## Add support for a multi-valued `genres` field

- Update metadata source plugins to populates `genres` instead of
`genre`: `musicbrainz`, `beatport`, `discogs`.
- Remove now redundant `separator` configuration from `lastgenre`.

### Context

We previously had multiple issues with maintaining both _singular_ and
_plural_ fields:

1. Since both fields write and read the same field in music files, the
values in both
fields must be carefully synchronised, otherwise we see these fields
being repeatedly
retagged / rewritten using commands such as `beet write`. See [related
issues](https://github.com/beetbox/beets/issues?q=label%3A"multi%20tags%22)
2. Fixes to sync logic required users manually retagging their
libraries, while music
   imported _as-is_ could not be fixed. See #5540, for example.

Therefore, this PR replaces a singular `genre` field by plural `genres`
_for good_:

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

### Migration

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

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

Supersedes: #6169
This commit is contained in:
Šarūnas Nejus 2026-02-27 18:42:46 +00:00 committed by GitHub
commit 16be1df940
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 652 additions and 365 deletions

View file

@ -24,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
@ -76,9 +77,22 @@ 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:
if genre is not None:
deprecate_for_maintainers(
"The 'genre' parameter", "'genres' (list)", stacklevel=3
)
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
self.artist_credit = artist_credit
@ -90,7 +104,8 @@ 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)

View file

@ -16,7 +16,6 @@
from __future__ import annotations
import contextlib
import functools
import os
import re
@ -24,19 +23,23 @@ 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 (
Callable,
Generator,
Iterable,
Iterator,
Mapping,
Sequence,
)
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
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,
@ -1008,12 +1011,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
@ -1023,11 +1029,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."""
@ -1036,6 +1054,32 @@ 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, *args, **kwargs) -> None:
"""Migrate a specific table."""
if not self.db.migration_exists(self.name, table):
self._migrate_data(table, *args, **kwargs)
self.db.record_migration(self.name, table)
@abstractmethod
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.
@ -1045,6 +1089,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"""
@ -1088,11 +1135,40 @@ 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()
@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:
@ -1193,7 +1269,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
@ -1233,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).
@ -1292,6 +1359,38 @@ 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."""
for migration_cls, model_classes in self._migrations:
migration = migration_cls(self)
for model_cls in model_classes:
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."""
return name in self.db_tables[table]["migrations"]
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(

View file

@ -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)

View file

@ -8,6 +8,7 @@ import beets
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
@ -19,6 +20,7 @@ class Library(dbcore.Database):
"""A database of music containing songs and albums."""
_models = (Item, Album)
_migrations = ((MultiGenreFieldMigration, (Item, Album)),)
def __init__(
self,

View file

@ -0,0 +1,97 @@
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, 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]
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")

View file

@ -241,7 +241,7 @@ 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,
"discogs_artistid": types.INTEGER,
@ -276,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]:
@ -297,7 +297,7 @@ class Album(LibModel):
"albumartist_credit",
"albumartists_credit",
"album",
"genre",
"genres",
"style",
"discogs_albumid",
"discogs_artistid",
@ -650,7 +650,7 @@ 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,
"discogs_artistid": types.INTEGER,
@ -732,7 +732,7 @@ class Item(LibModel):
"comments",
"album",
"albumartist",
"genre",
"genres",
)
# Set of item fields that are backed by `MediaFile` fields.

View file

@ -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",

View file

@ -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

View file

@ -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",
}

View file

@ -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
@ -233,8 +234,11 @@ 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"]]
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:
@ -253,7 +257,6 @@ class BeatportRelease(BeatportObject):
label_name: str | None
category: str | None
url: str | None
genre: str | None
tracks: list[BeatportTrack] | None = None
@ -263,7 +266,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 = (
@ -285,7 +287,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)
@ -306,12 +307,6 @@ 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.
if data.get("subGenres"):
self.genre = str(data["subGenres"][0].get("name"))
elif data.get("genres"):
self.genre = str(data["genres"][0].get("name"))
class BeatportPlugin(MetadataSourcePlugin):
_client: BeatportClient | None = None
@ -483,7 +478,7 @@ 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,
day=release_date.day if release_date else None,
@ -508,7 +503,7 @@ class BeatportPlugin(MetadataSourcePlugin):
data_url=track.url,
bpm=track.bpm,
initial_key=track.initial_key,
genre=track.genre,
genres=track.genres,
)
def _get_artist(self, artists):

View file

@ -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
@ -1351,7 +1354,7 @@ class Server(BaseServer):
"AlbumArtist": "albumartist",
"AlbumArtistSort": "albumartist_sort",
"Label": "label",
"Genre": "genre",
"Genre": "genres",
"Date": "year",
"OriginalDate": "original_year",
"Composer": "composer",

View file

@ -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],

View file

@ -16,10 +16,10 @@
"""This plugin generates tab completions for Beets commands for the Fish shell
<https://fishshell.com/>, 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

View file

@ -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
@ -111,7 +111,6 @@ class LastGenrePlugin(plugins.BeetsPlugin):
"force": False,
"keep_existing": False,
"auto": True,
"separator": ", ",
"prefer_specific": False,
"title_case": True,
"pretend": False,
@ -213,7 +212,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 +236,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 +260,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 +272,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.
@ -329,26 +330,21 @@ 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."""
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()
"""Return a list of genres for this Item or Album."""
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 genres_list
def _combine_resolve_and_log(
self, old: list[str], new: list[str]
@ -359,8 +355,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 +366,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
@ -381,7 +377,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
@ -391,7 +387,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 +396,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.
@ -479,33 +472,32 @@ class LastGenrePlugin(plugins.BeetsPlugin):
return result
# 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"
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 genres and self.config["keep_existing"]:
if valid_genres := self._filter_valid(genres):
return valid_genres, "original fallback"
# 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 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)
obj.genres, label = self._get_genre(obj)
self._log.debug("Resolved ({}): {}", label, obj.genres)
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:

View file

@ -644,10 +644,13 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
for source in sources:
for genreitem in source:
genres[genreitem["name"]] += int(genreitem["count"])
info.genre = "; ".join(
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()

View file

@ -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 = (

View file

@ -9,22 +9,49 @@ 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
: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 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.
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
~~~~~~~~~
..
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.
@ -32,6 +59,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 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)
-------------------------

View file

@ -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

View file

@ -28,7 +28,7 @@ option flags available to you, which also applies to subcommands such as ``beet
import -<TAB>``. 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<TAB>`` 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: <TAB>`` will display a
the latter case, subsequently typing ``beet list genres: <TAB>`` will display a
list of all the genres in your library and ``beet list albumartist: <TAB>`` 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

View file

@ -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

View file

@ -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,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**: A separator for multiple genres. 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:

View file

@ -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

View file

@ -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

View file

@ -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/

View file

@ -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

View file

@ -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

0
test/library/__init__.py Normal file
View file

View file

@ -0,0 +1,72 @@
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
class TestMultiGenreFieldMigration:
@pytest.fixture
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()
# 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"]
# 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")

View file

@ -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", "Breaks"]
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.genre == test_track.genre
assert track.genres == test_track.genres
class BeatportResponseEmptyTest(unittest.TestCase):
@ -634,7 +634,7 @@ class BeatportResponseEmptyTest(unittest.TestCase):
self.test_tracks[0]["subGenres"] = []
assert tracks[0].genre == self.test_tracks[0]["genres"][0]["name"]
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 +643,4 @@ class BeatportResponseEmptyTest(unittest.TestCase):
self.test_tracks[0]["genres"] = []
assert tracks[0].genre == self.test_tracks[0]["subGenres"][0]["name"]
assert tracks[0].genres == [self.test_tracks[0]["subGenres"][0]["name"]]

View file

@ -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):

View file

@ -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)

View file

@ -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."""
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."""
@ -204,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,
@ -215,13 +217,13 @@ class LastGenrePluginTest(IOMixin, PluginTestCase):
"prefer_specific": False,
"count": 10,
},
"Blues",
["Blues"],
{
"album": ["Jazz"],
},
("Blues, Jazz", "keep + album, whitelist"),
(["Blues", "Jazz"], "keep + album, whitelist"),
),
# 1 - force and keep whitelisted, unknown original
# force and keep whitelisted, unknown original
(
{
"force": True,
@ -231,13 +233,13 @@ class LastGenrePluginTest(IOMixin, PluginTestCase):
"canonical": False,
"prefer_specific": False,
},
"original unknown, Blues",
["original unknown", "Blues"],
{
"album": ["Jazz"],
},
("Blues, Jazz", "keep + album, whitelist"),
(["Blues", "Jazz"], "keep + album, whitelist"),
),
# 2 - force and keep whitelisted on empty tag
# force and keep whitelisted on empty tag
(
{
"force": True,
@ -247,13 +249,13 @@ class LastGenrePluginTest(IOMixin, PluginTestCase):
"canonical": False,
"prefer_specific": False,
},
"",
[],
{
"album": ["Jazz"],
},
("Jazz", "album, whitelist"),
(["Jazz"], "album, whitelist"),
),
# 3 force and keep, artist configured
# force and keep, artist configured
(
{
"force": True,
@ -263,14 +265,14 @@ class LastGenrePluginTest(IOMixin, PluginTestCase):
"canonical": False,
"prefer_specific": False,
},
"original unknown, Blues",
["original unknown", "Blues"],
{
"album": ["Jazz"],
"artist": ["Pop"],
},
("Blues, Pop", "keep + artist, whitelist"),
(["Blues", "Pop"], "keep + artist, whitelist"),
),
# 4 - don't force, disabled whitelist
# don't force, disabled whitelist
(
{
"force": False,
@ -280,13 +282,13 @@ class LastGenrePluginTest(IOMixin, PluginTestCase):
"canonical": False,
"prefer_specific": False,
},
"any genre",
["any genre"],
{
"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
# don't force and empty is regular last.fm fetch; no whitelist too
(
{
"force": False,
@ -296,13 +298,13 @@ class LastGenrePluginTest(IOMixin, PluginTestCase):
"canonical": False,
"prefer_specific": False,
},
"",
[],
{
"album": ["Jazzin"],
},
("Jazzin", "album, any"),
(["Jazzin"], "album, any"),
),
# 6 - fallback to next stages until found
# fallback to next stages until found
(
{
"force": True,
@ -312,15 +314,15 @@ class LastGenrePluginTest(IOMixin, PluginTestCase):
"canonical": False,
"prefer_specific": False,
},
"unknown genre",
["unknown genre"],
{
"track": None,
"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
# Keep the original genre when force and keep_existing are on, and
# whitelist is disabled
(
{
@ -332,15 +334,15 @@ class LastGenrePluginTest(IOMixin, PluginTestCase):
"canonical": False,
"prefer_specific": False,
},
"any existing",
["any existing"],
{
"track": None,
"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
# Keep the original genre when force and keep_existing are on, and
# whitelist is enabled, and genre is valid.
(
{
@ -352,15 +354,15 @@ class LastGenrePluginTest(IOMixin, PluginTestCase):
"canonical": False,
"prefer_specific": False,
},
"Jazz",
["Jazz"],
{
"track": None,
"album": None,
"artist": None,
},
("Jazz", "original fallback"),
(["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.
(
{
@ -372,15 +374,15 @@ class LastGenrePluginTest(IOMixin, PluginTestCase):
"canonical": False,
"prefer_specific": False,
},
"Jazz",
["Jazz"],
{
"track": None,
"album": None,
"artist": None,
},
("fallback genre", "fallback"),
(["fallback genre"], "fallback"),
),
# 8 - fallback to fallback if no original
# fallback to fallback if no original
(
{
"force": True,
@ -391,32 +393,15 @@ class LastGenrePluginTest(IOMixin, PluginTestCase):
"canonical": False,
"prefer_specific": False,
},
"",
[],
{
"track": None,
"album": None,
"artist": None,
},
("fallback genre", "fallback"),
(["fallback genre"], "fallback"),
),
# 9 - null charachter as separator
(
{
"force": True,
"keep_existing": True,
"source": "album",
"whitelist": True,
"separator": "\u0000",
"canonical": False,
"prefer_specific": False,
},
"Blues",
{
"album": ["Jazz"],
},
("Blues\u0000Jazz", "keep + album, whitelist"),
),
# 10 - limit a lot of results
# limit a lot of results
(
{
"force": True,
@ -426,31 +411,17 @@ class LastGenrePluginTest(IOMixin, PluginTestCase):
"count": 5,
"canonical": False,
"prefer_specific": False,
"separator": ", ",
},
"original unknown, Blues, Rock, Folk, Metal",
["original unknown", "Blues", "Rock", "Folk", "Metal"],
{
"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
(
{
"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
# fallback to next stage (artist) if no allowed original present
# and no album genre were fetched.
(
{
@ -462,15 +433,15 @@ class LastGenrePluginTest(IOMixin, PluginTestCase):
"canonical": False,
"prefer_specific": False,
},
"not whitelisted original",
["not whitelisted original"],
{
"track": None,
"album": None,
"artist": ["Jazz"],
},
("Jazz", "keep + artist, whitelist"),
(["Jazz"], "keep + artist, whitelist"),
),
# 13 - 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".
@ -484,13 +455,13 @@ class LastGenrePluginTest(IOMixin, PluginTestCase):
"prefer_specific": False,
"count": 10,
},
"",
[],
{
"album": ["acid techno"],
},
("Techno, Electronic", "album, whitelist"),
(["Techno", "Electronic"], "album, whitelist"),
),
# 14 - 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
@ -507,16 +478,22 @@ class LastGenrePluginTest(IOMixin, PluginTestCase):
"count": 10,
"extended_debug": True,
},
"detroit techno",
["detroit techno"],
{
"album": ["acid house"],
},
(
"Detroit Techno, Techno, Electronic, Acid House, House",
[
"Detroit Techno",
"Techno",
"Electronic",
"Acid House",
"House",
],
"keep + album, whitelist",
),
),
# 15 - 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
@ -532,16 +509,16 @@ class LastGenrePluginTest(IOMixin, PluginTestCase):
"prefer_specific": False,
"count": 10,
},
"Cosmic Disco",
["Cosmic Disco"],
{
"album": ["Detroit Techno"],
},
(
"Disco, Electronic, Detroit Techno, Techno",
["Disco", "Electronic", "Detroit Techno", "Techno"],
"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
@ -556,13 +533,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 +569,9 @@ 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
item.genres = item_genre
# Run
res = plugin._get_genre(item)
assert res == expected_result
assert plugin._get_genre(item) == expected_result

View file

@ -526,20 +526,20 @@ 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
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"]

View file

@ -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"
)

Binary file not shown.

View file

@ -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,
"genre": 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.genre == 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.genre == genre
assert item.genres == genres
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
@ -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"] = {
"genre": genre,
"genres": "; ".join(genres),
"collection": collection,
"disc": disc,
"comments": comments,
"album": "$album - formatted",
"disc": disc,
}
# As-is album import.
@ -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 == genres
assert album.comments == comments
for item in album.items():
assert item.get("genre", 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 (
@ -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 == genres
assert album.comments == comments
for item in album.items():
assert item.get("genre", 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 (

View file

@ -56,25 +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_genre = self.i.genre
self.i._values_fixed["genre"] = "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 genre from items where title = ?", (self.i.title,))
.fetchone()["genre"]
)
assert new_genre == original_genre
assert self.lib.get_item(self.i.id).year != new_year
def test_store_clears_dirty_flags(self):
self.i.composer = "tvp"

View file

@ -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),

View file

@ -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",

View file

@ -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

View file

@ -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))

View file

@ -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"),