diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py
index 82e685b7a..63ee52267 100644
--- a/beets/autotag/hooks.py
+++ b/beets/autotag/hooks.py
@@ -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)
diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py
index 8640a5678..cec6abc46 100755
--- a/beets/dbcore/db.py
+++ b/beets/dbcore/db.py
@@ -16,7 +16,6 @@
from __future__ import annotations
-import contextlib
import functools
import os
import re
@@ -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(
diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py
index 8907584a4..e50693474 100644
--- a/beets/dbcore/types.py
+++ b/beets/dbcore/types.py
@@ -30,6 +30,7 @@ from . import query
SQLiteType = query.SQLiteType
BLOB_TYPE = query.BLOB_TYPE
+MULTI_VALUE_DELIMITER = "\\␀"
class ModelType(typing.Protocol):
@@ -481,4 +482,4 @@ DATE = DateType()
SEMICOLON_SPACE_DSV = DelimitedString("; ")
# Will set the proper null char in mediafile
-MULTI_VALUE_DSV = DelimitedString("\\␀")
+MULTI_VALUE_DSV = DelimitedString(MULTI_VALUE_DELIMITER)
diff --git a/beets/library/library.py b/beets/library/library.py
index 39d559901..b161b7399 100644
--- a/beets/library/library.py
+++ b/beets/library/library.py
@@ -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,
diff --git a/beets/library/migrations.py b/beets/library/migrations.py
new file mode 100644
index 000000000..c061ddfc5
--- /dev/null
+++ b/beets/library/migrations.py
@@ -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")
diff --git a/beets/library/models.py b/beets/library/models.py
index 373c07ee3..9b8b6d291 100644
--- a/beets/library/models.py
+++ b/beets/library/models.py
@@ -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.
diff --git a/beets/test/_common.py b/beets/test/_common.py
index 4de47c337..b3c21aaa5 100644
--- a/beets/test/_common.py
+++ b/beets/test/_common.py
@@ -75,7 +75,7 @@ def item(lib=None, **kwargs):
artist="the artist",
albumartist="the album artist",
album="the album",
- genre="the genre",
+ genres=["the genre"],
lyricist="the lyricist",
composer="the composer",
arranger="the arranger",
diff --git a/beets/test/helper.py b/beets/test/helper.py
index 7762ab866..218b778c7 100644
--- a/beets/test/helper.py
+++ b/beets/test/helper.py
@@ -156,6 +156,8 @@ class TestHelper(RunMixin, ConfigMixin):
fixtures.
"""
+ lib: Library
+
resource_path = Path(os.fsdecode(_common.RSRC)) / "full.mp3"
db_on_disk: ClassVar[bool] = False
diff --git a/beetsplug/aura.py b/beetsplug/aura.py
index c1877db82..b30e66bf0 100644
--- a/beetsplug/aura.py
+++ b/beetsplug/aura.py
@@ -79,7 +79,8 @@ TRACK_ATTR_MAP = {
"month": "month",
"day": "day",
"bpm": "bpm",
- "genre": "genre",
+ "genre": "genres",
+ "genres": "genres",
"recording-mbid": "mb_trackid", # beets trackid is MB recording
"track-mbid": "mb_releasetrackid",
"composer": "composer",
@@ -109,7 +110,8 @@ ALBUM_ATTR_MAP = {
"year": "year",
"month": "month",
"day": "day",
- "genre": "genre",
+ "genre": "genres",
+ "genres": "genres",
"release-mbid": "mb_albumid",
"release-group-mbid": "mb_releasegroupid",
}
diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py
index 718e0730e..aa0693541 100644
--- a/beetsplug/beatport.py
+++ b/beetsplug/beatport.py
@@ -33,6 +33,7 @@ import beets
import beets.ui
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.metadata_plugins import MetadataSourcePlugin
+from beets.util import unique_list
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Sequence
@@ -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):
diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py
index 9496e9a78..16eb8c572 100644
--- a/beetsplug/bpd/__init__.py
+++ b/beetsplug/bpd/__init__.py
@@ -1137,7 +1137,10 @@ class Server(BaseServer):
pass
for tagtype, field in self.tagtype_map.items():
- info_lines.append(f"{tagtype}: {getattr(item, field)}")
+ field_value = getattr(item, field)
+ if isinstance(field_value, list):
+ field_value = "; ".join(field_value)
+ info_lines.append(f"{tagtype}: {field_value}")
return info_lines
@@ -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",
diff --git a/beetsplug/discogs/__init__.py b/beetsplug/discogs/__init__.py
index bdbeb8fc0..b33af83a2 100644
--- a/beetsplug/discogs/__init__.py
+++ b/beetsplug/discogs/__init__.py
@@ -352,12 +352,11 @@ class DiscogsPlugin(MetadataSourcePlugin):
mediums = [t["medium"] for t in tracks]
country = result.data.get("country")
data_url = result.data.get("uri")
- style = self.format(result.data.get("styles"))
- base_genre = self.format(result.data.get("genres"))
+ styles: list[str] = result.data.get("styles") or []
+ genres: list[str] = result.data.get("genres") or []
- genre = base_genre
- if self.config["append_style_genre"] and genre is not None and style:
- genre += f"{self.config['separator'].as_str()}{style}"
+ if self.config["append_style_genre"]:
+ genres.extend(styles)
discogs_albumid = self._extract_id(result.data.get("uri"))
@@ -411,8 +410,10 @@ class DiscogsPlugin(MetadataSourcePlugin):
releasegroup_id=master_id,
catalognum=catalogno,
country=country,
- style=style,
- genre=genre,
+ style=(
+ self.config["separator"].as_str().join(sorted(styles)) or None
+ ),
+ genres=sorted(genres),
media=media,
original_year=original_year,
data_source=self.data_source,
@@ -433,14 +434,6 @@ class DiscogsPlugin(MetadataSourcePlugin):
return None
- def format(self, classification: Iterable[str]) -> str | None:
- if classification:
- return (
- self.config["separator"].as_str().join(sorted(classification))
- )
- else:
- return None
-
def get_tracks(
self,
tracklist: list[Track],
diff --git a/beetsplug/fish.py b/beetsplug/fish.py
index 82e035eb4..9de764656 100644
--- a/beetsplug/fish.py
+++ b/beetsplug/fish.py
@@ -16,10 +16,10 @@
"""This plugin generates tab completions for Beets commands for the Fish shell
, including completions for Beets commands, plugin
commands, and option flags. Also generated are completions for all the album
-and track fields, suggesting for example `genre:` or `album:` when querying the
+and track fields, suggesting for example `genres:` or `album:` when querying the
Beets database. Completions for the *values* of those fields are not generated
by default but can be added via the `-e` / `--extravalues` flag. For example:
-`beet fish -e genre -e albumartist`
+`beet fish -e genres -e albumartist`
"""
import os
diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py
index f7aef0261..41927a87c 100644
--- a/beetsplug/lastgenre/__init__.py
+++ b/beetsplug/lastgenre/__init__.py
@@ -39,7 +39,7 @@ from beets.util import plurality, unique_list
if TYPE_CHECKING:
import optparse
- from collections.abc import Callable
+ from collections.abc import Callable, Iterable
from beets.importer import ImportSession, ImportTask
from beets.library import LibModel
@@ -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:
diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py
index ffef366ae..090bd617a 100644
--- a/beetsplug/musicbrainz.py
+++ b/beetsplug/musicbrainz.py
@@ -644,10 +644,13 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
for source in sources:
for genreitem in source:
genres[genreitem["name"]] += int(genreitem["count"])
- info.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()
diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py
index a5cc8e362..ff5e25612 100644
--- a/beetsplug/smartplaylist.py
+++ b/beetsplug/smartplaylist.py
@@ -359,8 +359,8 @@ class SmartPlaylistPlugin(BeetsPlugin):
if extm3u:
attr = [(k, entry.item[k]) for k in keys]
al = [
- f' {key}="{quote(str(value), safe="/:")}"'
- for key, value in attr
+ f' {k}="{quote("; ".join(v) if isinstance(v, list) else str(v), safe="/:")}"' # noqa: E501
+ for k, v in attr
]
attrs = "".join(al)
comment = (
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 9f73a5725..6cd8d7623 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -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)
-------------------------
diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst
index 780042026..3734b57e7 100644
--- a/docs/plugins/discogs.rst
+++ b/docs/plugins/discogs.rst
@@ -116,17 +116,21 @@ Default
.. conf:: append_style_genre
:default: no
- Appends the Discogs style (if found) to the genre tag. This can be useful if
- you want more granular genres to categorize your music. For example,
- a release in Discogs might have a genre of "Electronic" and a style of
- "Techno": enabling this setting would set the genre to be "Electronic,
- Techno" (assuming default separator of ``", "``) instead of just
- "Electronic".
+ Appends the Discogs style (if found) to the ``genres`` tag. This can be
+ useful if you want more granular genres to categorize your music. For
+ example, a release in Discogs might have a genre of "Electronic" and a style
+ of "Techno": enabling this setting would append "Techno" to the ``genres``
+ list.
.. conf:: separator
:default: ", "
- How to join multiple genre and style values from Discogs into a string.
+ How to join multiple style values from Discogs into a string.
+
+ .. versionchanged:: 2.7.0
+
+ This option now only applies to the ``style`` field as beets now only
+ handles lists of ``genres``.
.. conf:: strip_disambiguation
:default: yes
diff --git a/docs/plugins/fish.rst b/docs/plugins/fish.rst
index c1ae4f990..a26b06458 100644
--- a/docs/plugins/fish.rst
+++ b/docs/plugins/fish.rst
@@ -28,7 +28,7 @@ option flags available to you, which also applies to subcommands such as ``beet
import -``. If you type ``beet ls`` followed by a space and then the and
the ``TAB`` key, you will see a list of all the album/track fields that can be
used in beets queries. For example, typing ``beet ls ge`` will complete to
-``genre:`` and leave you ready to type the rest of your query.
+``genres:`` and leave you ready to type the rest of your query.
Options
-------
@@ -42,7 +42,7 @@ commands and option flags.
If you want generated completions to also contain album/track field *values* for
the items in your library, you can use the ``-e`` or ``--extravalues`` option.
For example: ``beet fish -e genre`` or ``beet fish -e genre -e albumartist`` In
-the latter case, subsequently typing ``beet list genre: `` will display a
+the latter case, subsequently typing ``beet list genres: `` will display a
list of all the genres in your library and ``beet list albumartist: `` will
show a list of the album artists in your library. Keep in mind that all of these
values will be put into the generated completions file, so use this option with
diff --git a/docs/plugins/ihate.rst b/docs/plugins/ihate.rst
index 47e679dbd..6bb76d796 100644
--- a/docs/plugins/ihate.rst
+++ b/docs/plugins/ihate.rst
@@ -26,12 +26,12 @@ Here's an example:
ihate:
warn:
- artist:rnb
- - genre:soul
+ - genres:soul
# Only warn about tribute albums in rock genre.
- - genre:rock album:tribute
+ - genres:rock album:tribute
skip:
- - genre::russian\srock
- - genre:polka
+ - genres::russian\srock
+ - genres:polka
- artist:manowar
- album:christmas
diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst
index ace7caaf0..fa68ce9db 100644
--- a/docs/plugins/lastgenre.rst
+++ b/docs/plugins/lastgenre.rst
@@ -90,9 +90,8 @@ By default, the plugin chooses the most popular tag on Last.fm as a genre. If
you prefer to use a *list* of popular genre tags, you can increase the number of
the ``count`` config option.
-Lists of up to *count* genres will then be used instead of single genres. The
-genres are separated by commas by default, but you can change this with the
-``separator`` config option.
+Lists of up to *count* genres will be stored in the ``genres`` field as a list
+and written to your media files as separate genre tags.
Last.fm_ provides a popularity factor, a.k.a. *weight*, for each tag ranging
from 100 for the most popular tag down to 0 for the least popular. The plugin
@@ -192,7 +191,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:
diff --git a/docs/plugins/smartplaylist.rst b/docs/plugins/smartplaylist.rst
index f227559a8..48060ea79 100644
--- a/docs/plugins/smartplaylist.rst
+++ b/docs/plugins/smartplaylist.rst
@@ -121,7 +121,7 @@ instance the following configuration exports the ``id`` and ``genre`` fields:
output: extm3u
fields:
- id
- - genre
+ - genres
playlists:
- name: all.m3u
query: ''
@@ -132,7 +132,7 @@ look as follows:
::
#EXTM3U
- #EXTINF:805 id="1931" genre="Progressive%20Rock",Led Zeppelin - Stairway to Heaven
+ #EXTINF:805 id="1931" genres="Rock%3B%20Pop",Led Zeppelin - Stairway to Heaven
../music/singles/Led Zeppelin/Stairway to Heaven.mp3
To give a usage example, the webm3u_ and Beetstream_ plugins read the exported
diff --git a/docs/plugins/zero.rst b/docs/plugins/zero.rst
index bf134e664..914e28faf 100644
--- a/docs/plugins/zero.rst
+++ b/docs/plugins/zero.rst
@@ -45,7 +45,6 @@ For example:
zero:
fields: month day genre genres comments
comments: [EAC, LAME, from.+collection, 'ripped by']
- genre: [rnb, 'power metal']
genres: [rnb, 'power metal']
update_database: true
diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst
index 15024022b..6f60d2232 100644
--- a/docs/reference/cli.rst
+++ b/docs/reference/cli.rst
@@ -143,9 +143,9 @@ Optional command flags:
:ref:`set_fields` configuration dictionary. You can use the option multiple
times on the command line, like so:
- ::
+.. code-block:: sh
- beet import --set genre="Alternative Rock" --set mood="emotional"
+ beet import --set genres="Alternative Rock" --set mood="emotional"
.. _py7zr: https://pypi.org/project/py7zr/
diff --git a/docs/reference/config.rst b/docs/reference/config.rst
index b654c118f..fc0de37a7 100644
--- a/docs/reference/config.rst
+++ b/docs/reference/config.rst
@@ -853,11 +853,11 @@ set_fields
A dictionary indicating fields to set to values for newly imported music. Here's
an example:
-::
+.. code-block:: yaml
set_fields:
- genre: 'To Listen'
- collection: 'Unordered'
+ genres: To Listen
+ collection: Unordered
Other field/value pairs supplied via the ``--set`` option on the command-line
override any settings here for fields with the same name.
@@ -1172,9 +1172,9 @@ Here's an example file:
color: yes
paths:
- default: $genre/$albumartist/$album/$track $title
+ default: %first{$genres}/$albumartist/$album/$track $title
singleton: Singletons/$artist - $title
- comp: $genre/$album/$track $title
+ comp: %first{$genres}/$album/$track $title
albumtype:soundtrack: Soundtracks/$album/$track $title
.. only:: man
diff --git a/test/autotag/test_hooks.py b/test/autotag/test_hooks.py
new file mode 100644
index 000000000..e5de089e8
--- /dev/null
+++ b/test/autotag/test_hooks.py
@@ -0,0 +1,17 @@
+import pytest
+
+from beets.autotag.hooks import Info
+
+
+@pytest.mark.parametrize(
+ "genre, expected_genres",
+ [
+ ("Rock", ("Rock",)),
+ ("Rock; Alternative", ("Rock", "Alternative")),
+ ],
+)
+def test_genre_deprecation(genre, expected_genres):
+ with pytest.warns(
+ DeprecationWarning, match="The 'genre' parameter is deprecated"
+ ):
+ assert tuple(Info(genre=genre).genres) == expected_genres
diff --git a/test/library/__init__.py b/test/library/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/library/test_migrations.py b/test/library/test_migrations.py
new file mode 100644
index 000000000..2c0dece8b
--- /dev/null
+++ b/test/library/test_migrations.py
@@ -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")
diff --git a/test/plugins/test_beatport.py b/test/plugins/test_beatport.py
index b92a3bf15..96386d8b6 100644
--- a/test/plugins/test_beatport.py
+++ b/test/plugins/test_beatport.py
@@ -474,7 +474,7 @@ class BeatportTest(BeetsTestCase):
item.year = 2016
item.comp = False
item.label_name = "Gravitas Recordings"
- item.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"]]
diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py
index 15d47db6c..cef84e3a9 100644
--- a/test/plugins/test_discogs.py
+++ b/test/plugins/test_discogs.py
@@ -362,7 +362,7 @@ class DGAlbumInfoTest(BeetsTestCase):
release = self._make_release_from_positions(["1", "2"])
d = DiscogsPlugin().get_album_info(release)
- assert d.genre == "GENRE1, GENRE2"
+ assert d.genres == ["GENRE1", "GENRE2"]
assert d.style == "STYLE1, STYLE2"
def test_append_style_to_genre(self):
@@ -371,7 +371,7 @@ class DGAlbumInfoTest(BeetsTestCase):
release = self._make_release_from_positions(["1", "2"])
d = DiscogsPlugin().get_album_info(release)
- assert d.genre == "GENRE1, GENRE2, STYLE1, STYLE2"
+ assert d.genres == ["GENRE1", "GENRE2", "STYLE1", "STYLE2"]
assert d.style == "STYLE1, STYLE2"
def test_append_style_to_genre_no_style(self):
@@ -381,7 +381,7 @@ class DGAlbumInfoTest(BeetsTestCase):
release.data["styles"] = []
d = DiscogsPlugin().get_album_info(release)
- assert d.genre == "GENRE1, GENRE2"
+ assert d.genres == ["GENRE1", "GENRE2"]
assert d.style is None
def test_strip_disambiguation(self):
diff --git a/test/plugins/test_ihate.py b/test/plugins/test_ihate.py
index f941d566c..b64b8d91d 100644
--- a/test/plugins/test_ihate.py
+++ b/test/plugins/test_ihate.py
@@ -11,7 +11,7 @@ class IHatePluginTest(unittest.TestCase):
def test_hate(self):
match_pattern = {}
test_item = Item(
- genre="TestGenre", album="TestAlbum", artist="TestArtist"
+ genres=["TestGenre"], album="TestAlbum", artist="TestArtist"
)
task = importer.SingletonImportTask(None, test_item)
@@ -27,19 +27,19 @@ class IHatePluginTest(unittest.TestCase):
assert IHatePlugin.do_i_hate_this(task, match_pattern)
# Query is blocked by AND clause.
- match_pattern = ["album:notthis genre:testgenre"]
+ match_pattern = ["album:notthis genres:testgenre"]
assert not IHatePlugin.do_i_hate_this(task, match_pattern)
# Both queries are blocked by AND clause with unmatched condition.
match_pattern = [
- "album:notthis genre:testgenre",
+ "album:notthis genres:testgenre",
"artist:testartist album:notthis",
]
assert not IHatePlugin.do_i_hate_this(task, match_pattern)
# Only one query should fire.
match_pattern = [
- "album:testalbum genre:testgenre",
+ "album:testalbum genres:testgenre",
"artist:testartist album:notthis",
]
assert IHatePlugin.do_i_hate_this(task, match_pattern)
diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py
index f499992c6..55524d3fc 100644
--- a/test/plugins/test_lastgenre.py
+++ b/test/plugins/test_lastgenre.py
@@ -80,13 +80,15 @@ class LastGenrePluginTest(IOMixin, PluginTestCase):
self._setup_config(canonical="", whitelist={"rock"})
assert self.plugin._resolve_genres(["delta blues"]) == []
- def test_format_and_stringify(self):
- """Format genres list and return them as a separator-delimited string."""
+ def test_format_genres(self):
+ """Format genres list."""
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
diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py
index 8d7c5a2f8..e000e16ec 100644
--- a/test/plugins/test_musicbrainz.py
+++ b/test/plugins/test_musicbrainz.py
@@ -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"]
diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py
index 7cc712330..d1125158f 100644
--- a/test/plugins/test_smartplaylist.py
+++ b/test/plugins/test_smartplaylist.py
@@ -76,11 +76,11 @@ class SmartPlaylistTest(BeetsTestCase):
{"name": "one_non_empty_sort", "query": ["foo year+", "bar"]},
{
"name": "multiple_sorts",
- "query": ["foo year+", "bar genre-"],
+ "query": ["foo year+", "bar genres-"],
},
{
"name": "mixed",
- "query": ["foo year+", "bar", "baz genre+ id-"],
+ "query": ["foo year+", "bar", "baz genres+ id-"],
},
]
)
@@ -102,11 +102,11 @@ class SmartPlaylistTest(BeetsTestCase):
# Multiple queries store individual sorts in the tuple
assert all(isinstance(x, NullSort) for x in sorts["only_empty_sorts"])
assert sorts["one_non_empty_sort"] == [sort("year"), NullSort()]
- assert sorts["multiple_sorts"] == [sort("year"), sort("genre", False)]
+ assert sorts["multiple_sorts"] == [sort("year"), sort("genres", False)]
assert sorts["mixed"] == [
sort("year"),
NullSort(),
- MultipleSort([sort("genre"), sort("id", False)]),
+ MultipleSort([sort("genres"), sort("id", False)]),
]
def test_matches(self):
@@ -259,7 +259,7 @@ class SmartPlaylistTest(BeetsTestCase):
type(i).title = PropertyMock(return_value="fake Title")
type(i).length = PropertyMock(return_value=300.123)
type(i).path = PropertyMock(return_value=b"/tagada.mp3")
- a = {"id": 456, "genre": "Fake Genre"}
+ a = {"id": 456, "genres": ["Rock", "Pop"]}
i.__getitem__.side_effect = a.__getitem__
i.evaluate_template.side_effect = lambda pl, _: pl.replace(
b"$title",
@@ -280,7 +280,7 @@ class SmartPlaylistTest(BeetsTestCase):
config["smartplaylist"]["output"] = "extm3u"
config["smartplaylist"]["relative_to"] = False
config["smartplaylist"]["playlist_dir"] = str(dir)
- config["smartplaylist"]["fields"] = ["id", "genre"]
+ config["smartplaylist"]["fields"] = ["id", "genres"]
try:
spl.update_playlists(lib)
except Exception:
@@ -297,7 +297,7 @@ class SmartPlaylistTest(BeetsTestCase):
assert content == (
b"#EXTM3U\n"
- b'#EXTINF:300 id="456" genre="Fake%20Genre",Fake Artist - fake Title\n'
+ b'#EXTINF:300 id="456" genres="Rock%3B%20Pop",Fake Artist - fake Title\n'
b"/tagada.mp3\n"
)
diff --git a/test/rsrc/unicode’d.mp3 b/test/rsrc/unicode’d.mp3
index f7e8b6285..2b306cc13 100644
Binary files a/test/rsrc/unicode’d.mp3 and b/test/rsrc/unicode’d.mp3 differ
diff --git a/test/test_importer.py b/test/test_importer.py
index 6ae7d562b..a7d57dbb2 100644
--- a/test/test_importer.py
+++ b/test/test_importer.py
@@ -304,15 +304,15 @@ class ImportSingletonTest(AutotagImportTestCase):
assert len(self.lib.albums()) == 2
def test_set_fields(self):
- genre = "\U0001f3b7 Jazz"
+ genres = ["\U0001f3b7 Jazz", "Rock"]
collection = "To Listen"
disc = 0
config["import"]["set_fields"] = {
+ "genres": "; ".join(genres),
"collection": collection,
- "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 (
diff --git a/test/test_library.py b/test/test_library.py
index 4df4e4b58..5af6f76d8 100644
--- a/test/test_library.py
+++ b/test/test_library.py
@@ -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"
diff --git a/test/test_query.py b/test/test_query.py
index 0ddf83e3a..81532c436 100644
--- a/test/test_query.py
+++ b/test/test_query.py
@@ -71,7 +71,7 @@ class TestGet:
album="baz",
year=2001,
comp=True,
- genre="rock",
+ genres=["rock"],
),
helper.create_item(
title="second",
@@ -80,7 +80,7 @@ class TestGet:
album="baz",
year=2002,
comp=True,
- genre="Rock",
+ genres=["Rock"],
),
]
album = helper.lib.add_album(album_items)
@@ -94,7 +94,7 @@ class TestGet:
album="foo",
year=2003,
comp=False,
- genre="Hard Rock",
+ genres=["Hard Rock"],
comments="caf\xe9",
)
@@ -125,12 +125,12 @@ class TestGet:
("comments:caf\xe9", ["third"]),
("comp:true", ["first", "second"]),
("comp:false", ["third"]),
- ("genre:=rock", ["first"]),
- ("genre:=Rock", ["second"]),
- ('genre:="Hard Rock"', ["third"]),
- ('genre:=~"hard rock"', ["third"]),
- ("genre:=~rock", ["first", "second"]),
- ('genre:="hard rock"', []),
+ ("genres:=rock", ["first"]),
+ ("genres:=Rock", ["second"]),
+ ('genres:="Hard Rock"', ["third"]),
+ ('genres:=~"hard rock"', ["third"]),
+ ("genres:=~rock", ["first", "second"]),
+ ('genres:="hard rock"', []),
("popebear", []),
("pope:bear", []),
("singleton:true", ["third"]),
@@ -243,13 +243,7 @@ class TestGet:
class TestMatch:
@pytest.fixture(scope="class")
def item(self):
- return _common.item(
- album="the album",
- disc=6,
- genre="the genre",
- year=1,
- bitrate=128000,
- )
+ return _common.item(album="the album", disc=6, year=1, bitrate=128000)
@pytest.mark.parametrize(
"q, should_match",
@@ -260,9 +254,9 @@ class TestMatch:
(SubstringQuery("album", "album"), True),
(SubstringQuery("album", "ablum"), False),
(SubstringQuery("disc", "6"), True),
- (StringQuery("genre", "the genre"), True),
- (StringQuery("genre", "THE GENRE"), True),
- (StringQuery("genre", "genre"), False),
+ (StringQuery("album", "the album"), True),
+ (StringQuery("album", "THE ALBUM"), True),
+ (StringQuery("album", "album"), False),
(NumericQuery("year", "1"), True),
(NumericQuery("year", "10"), False),
(NumericQuery("bitrate", "100000..200000"), True),
diff --git a/test/test_sort.py b/test/test_sort.py
index 460aa07b8..d7d651de5 100644
--- a/test/test_sort.py
+++ b/test/test_sort.py
@@ -33,7 +33,7 @@ class DummyDataTestCase(BeetsTestCase):
albums = [
Album(
album="Album A",
- genre="Rock",
+ genres=["Rock"],
year=2001,
flex1="Flex1-1",
flex2="Flex2-A",
@@ -41,7 +41,7 @@ class DummyDataTestCase(BeetsTestCase):
),
Album(
album="Album B",
- genre="Rock",
+ genres=["Rock"],
year=2001,
flex1="Flex1-2",
flex2="Flex2-A",
@@ -49,7 +49,7 @@ class DummyDataTestCase(BeetsTestCase):
),
Album(
album="Album C",
- genre="Jazz",
+ genres=["Jazz"],
year=2005,
flex1="Flex1-1",
flex2="Flex2-B",
@@ -236,19 +236,19 @@ class SortAlbumFixedFieldTest(DummyDataTestCase):
def test_sort_two_field_asc(self):
q = ""
- s1 = dbcore.query.FixedFieldSort("genre", True)
+ s1 = dbcore.query.FixedFieldSort("genres", True)
s2 = dbcore.query.FixedFieldSort("album", True)
sort = dbcore.query.MultipleSort()
sort.add_sort(s1)
sort.add_sort(s2)
results = self.lib.albums(q, sort)
- assert results[0]["genre"] <= results[1]["genre"]
- assert results[1]["genre"] <= results[2]["genre"]
- assert results[1]["genre"] == "Rock"
- assert results[2]["genre"] == "Rock"
+ assert results[0]["genres"] <= results[1]["genres"]
+ assert results[1]["genres"] <= results[2]["genres"]
+ assert results[1]["genres"] == ["Rock"]
+ assert results[2]["genres"] == ["Rock"]
assert results[1]["album"] <= results[2]["album"]
# same thing with query string
- q = "genre+ album+"
+ q = "genres+ album+"
results2 = self.lib.albums(q)
for r1, r2 in zip(results, results2):
assert r1.id == r2.id
@@ -388,7 +388,7 @@ class CaseSensitivityTest(DummyDataTestCase):
album = Album(
album="album",
- genre="alternative",
+ genres=["alternative"],
year="2001",
flex1="flex1",
flex2="flex2-A",
diff --git a/test/ui/commands/test_list.py b/test/ui/commands/test_list.py
index 372d75410..0828980ca 100644
--- a/test/ui/commands/test_list.py
+++ b/test/ui/commands/test_list.py
@@ -63,6 +63,6 @@ class ListTest(IOMixin, BeetsTestCase):
assert "the artist - the album - 0001" == stdout.strip()
def test_list_album_format(self):
- stdout = self._run_list(album=True, fmt="$genre")
+ stdout = self._run_list(album=True, fmt="$genres")
assert "the genre" in stdout
assert "the album" not in stdout
diff --git a/test/ui/commands/test_update.py b/test/ui/commands/test_update.py
index 3fb687418..937ded10c 100644
--- a/test/ui/commands/test_update.py
+++ b/test/ui/commands/test_update.py
@@ -103,22 +103,22 @@ class UpdateTest(IOMixin, BeetsTestCase):
def test_selective_modified_metadata_moved(self):
mf = MediaFile(syspath(self.i.path))
mf.title = "differentTitle"
- mf.genre = "differentGenre"
+ mf.genres = ["differentGenre"]
mf.save()
self._update(move=True, fields=["title"])
item = self.lib.items().get()
assert b"differentTitle" in item.path
- assert item.genre != "differentGenre"
+ assert item.genres != ["differentGenre"]
def test_selective_modified_metadata_not_moved(self):
mf = MediaFile(syspath(self.i.path))
mf.title = "differentTitle"
- mf.genre = "differentGenre"
+ mf.genres = ["differentGenre"]
mf.save()
self._update(move=False, fields=["title"])
item = self.lib.items().get()
assert b"differentTitle" not in item.path
- assert item.genre != "differentGenre"
+ assert item.genres != ["differentGenre"]
def test_modified_album_metadata_moved(self):
mf = MediaFile(syspath(self.i.path))
@@ -141,22 +141,22 @@ class UpdateTest(IOMixin, BeetsTestCase):
def test_selective_modified_album_metadata_moved(self):
mf = MediaFile(syspath(self.i.path))
mf.album = "differentAlbum"
- mf.genre = "differentGenre"
+ mf.genres = ["differentGenre"]
mf.save()
self._update(move=True, fields=["album"])
item = self.lib.items().get()
assert b"differentAlbum" in item.path
- assert item.genre != "differentGenre"
+ assert item.genres != ["differentGenre"]
def test_selective_modified_album_metadata_not_moved(self):
mf = MediaFile(syspath(self.i.path))
mf.album = "differentAlbum"
- mf.genre = "differentGenre"
+ mf.genres = ["differentGenre"]
mf.save()
- self._update(move=True, fields=["genre"])
+ self._update(move=True, fields=["genres"])
item = self.lib.items().get()
assert b"differentAlbum" not in item.path
- assert item.genre == "differentGenre"
+ assert item.genres == ["differentGenre"]
def test_mtime_match_skips_update(self):
mf = MediaFile(syspath(self.i.path))
diff --git a/test/ui/test_field_diff.py b/test/ui/test_field_diff.py
index d42e55a93..24bac0123 100644
--- a/test/ui/test_field_diff.py
+++ b/test/ui/test_field_diff.py
@@ -34,8 +34,8 @@ class TestFieldDiff:
p({"title": "foo"}, {"title": "bar"}, "title", f"title: {diff_fmt('foo', 'bar')}", id="string_full_replace"),
p({"title": "prefix foo"}, {"title": "prefix bar"}, "title", "title: prefix [text_diff_removed]foo[/] -> prefix [text_diff_added]bar[/]", id="string_partial_change"),
p({"year": 2000}, {"year": 2001}, "year", f"year: {diff_fmt('2000', '2001')}", id="int_changed"),
- p({}, {"genre": "Rock"}, "genre", "genre: -> [text_diff_added]Rock[/]", id="field_added"),
- p({"genre": "Rock"}, {}, "genre", "genre: [text_diff_removed]Rock[/] -> ", id="field_removed"),
+ p({}, {"artist": "Artist"}, "artist", "artist: -> [text_diff_added]Artist[/]", id="field_added"),
+ p({"artist": "Artist"}, {}, "artist", "artist: [text_diff_removed]Artist[/] -> ", id="field_removed"),
p({"track": 1}, {"track": 2}, "track", f"track: {diff_fmt('01', '02')}", id="formatted_value_changed"),
p({"mb_trackid": None}, {"mb_trackid": "1234"}, "mb_trackid", "mb_trackid: -> [text_diff_added]1234[/]", id="none_to_value"),
p({}, {"new_flex": "foo"}, "new_flex", "[text_diff_added]new_flex: foo[/]", id="flex_field_added"),