mirror of
https://github.com/beetbox/beets.git
synced 2026-03-19 20:06:14 +01:00
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:
commit
16be1df940
42 changed files with 652 additions and 365 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
97
beets/library/migrations.py
Normal file
97
beets/library/migrations.py
Normal 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")
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
-------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
17
test/autotag/test_hooks.py
Normal file
17
test/autotag/test_hooks.py
Normal 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
0
test/library/__init__.py
Normal file
72
test/library/test_migrations.py
Normal file
72
test/library/test_migrations.py
Normal 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")
|
||||
|
|
@ -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"]]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Reference in a new issue