diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 42f957b0d5..6b30846d2c 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -118,7 +118,7 @@ def _apply_metadata( if value is None and field not in nullable_fields: continue - db_obj[field] = value + setattr(db_obj, field, value) def correct_list_fields(m: LibModel) -> None: diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 3fa80c6f35..497722f862 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -100,6 +100,7 @@ def __init__( country: str | None = None, style: str | None = None, genre: str | None = None, + genres: str | None = None, albumstatus: str | None = None, media: str | None = None, albumdisambig: str | None = None, @@ -143,6 +144,7 @@ def __init__( self.country = country self.style = style self.genre = genre + self.genres = genres or ([genre] if genre else []) self.albumstatus = albumstatus self.media = media self.albumdisambig = albumdisambig @@ -212,6 +214,7 @@ def __init__( bpm: str | None = None, initial_key: str | None = None, genre: str | None = None, + genres: str | None = None, album: str | None = None, **kwargs, ): @@ -245,7 +248,7 @@ def __init__( self.work_disambig = work_disambig self.bpm = bpm self.initial_key = initial_key - self.genre = genre + self.genres = genres self.album = album self.update(kwargs) diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 90c2013d8b..c242095f47 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -614,10 +614,10 @@ def album_info(release: dict) -> beets.autotag.hooks.AlbumInfo: for source in sources: for genreitem in source: genres[genreitem["name"]] += int(genreitem["count"]) - info.genre = "; ".join( + 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 = config["musicbrainz"]["external_ids"].get() @@ -808,7 +808,6 @@ def _merge_pseudo_and_actual_album( "barcode", "asin", "style", - "genre", ] } merged.update(from_actual) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 2aa0081d79..f226eccfea 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -432,7 +432,7 @@ def copy(self) -> Model: # Essential field accessors. @classmethod - def _type(cls, key) -> types.Type: + def _type(cls, key): """Get the type of a field, a `Type` instance. If the field has no explicit type, it is given the base `Type`, @@ -528,7 +528,7 @@ def all_keys(cls): def update(self, values): """Assign all values in the given dict.""" for key, value in values.items(): - self[key] = value + setattr(self, key, value) def items(self) -> Iterator[tuple[str, Any]]: """Iterate over (key, value) pairs that this object contains. @@ -559,7 +559,11 @@ def __getattr__(self, key): raise AttributeError(f"no such field {key!r}") def __setattr__(self, key, value): - if key.startswith("_"): + if ( + key.startswith("_") + or key in dir(self) + and isinstance(getattr(self.__class__, key), property) + ): super().__setattr__(key, value) else: self[key] = value @@ -714,7 +718,7 @@ def _parse(cls, key, string: str) -> Any: def set_parse(self, key, string: str): """Set the object's key to a value represented by a string.""" - self[key] = self._parse(key, string) + setattr(self, key, self._parse(key, string)) # Database controller and supporting interfaces. diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py index 2a64b2ed94..067ee92f55 100644 --- a/beets/dbcore/types.py +++ b/beets/dbcore/types.py @@ -304,7 +304,13 @@ def parse(self, string: str): return [] return string.split(self.delimiter) - def to_sql(self, model_value: list[str]): + def normalize(self, value: Any) -> list[str]: + if not value: + return [] + + return value.split(self.delimiter) if isinstance(value, str) else value + + def to_sql(self, model_value: list[str]) -> str: return self.delimiter.join(model_value) diff --git a/beets/library.py b/beets/library.py index d4ec63200d..1cf2acaf42 100644 --- a/beets/library.py +++ b/beets/library.py @@ -354,10 +354,27 @@ class LibModel(dbcore.Model["Library"]): def writable_media_fields(cls) -> set[str]: return set(MediaFile.fields()) & cls._fields.keys() + @property + def genre(self) -> str: + _type: types.DelimitedString = self._type("genres") + return _type.to_sql(self.get("genres")) + + @genre.setter + def genre(self, value: str) -> None: + self.genres = value + + @classmethod + def _getters(cls): + return { + "genre": lambda m: cls._fields["genres"].delimiter.join(m.genres) + } + def _template_funcs(self): - funcs = DefaultTemplateFunctions(self, self._db).functions() - funcs.update(plugins.template_funcs()) - return funcs + return { + **DefaultTemplateFunctions(self, self._db).functions(), + **plugins.template_funcs(), + "genre": "$genres", + } def store(self, fields=None): super().store(fields) @@ -533,7 +550,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.SEMICOLON_SPACE_DSV, "style": types.STRING, "discogs_albumid": types.INTEGER, "discogs_artistid": types.INTEGER, @@ -614,7 +631,7 @@ class Item(LibModel): "comments", "album", "albumartist", - "genre", + "genres", ) _types = { @@ -689,10 +706,12 @@ def _cached_album(self, album): @classmethod def _getters(cls): - getters = plugins.item_field_getters() - getters["singleton"] = lambda i: i.album_id is None - getters["filesize"] = Item.try_filesize # In bytes. - return getters + return { + **plugins.item_field_getters(), + "singleton": lambda i: i.album_id is None, + "filesize": Item.try_filesize, # In bytes. + "genre": lambda i: cls._fields["genres"].delimiter.join(i.genres), + } def duplicates_query(self, fields: list[str]) -> dbcore.AndQuery: """Return a query for entities with same values in the given fields.""" @@ -768,6 +787,10 @@ def get(self, key, default=None, with_album=True): Set `with_album` to false to skip album fallback. """ + if key in dir(self) and isinstance( + getattr(self.__class__, key), property + ): + return getattr(self, key) try: return self._get(key, default, raise_=with_album) except KeyError: @@ -1181,7 +1204,7 @@ class Album(LibModel): "albumartists_sort": types.MULTI_VALUE_DSV, "albumartists_credit": types.MULTI_VALUE_DSV, "album": types.STRING, - "genre": types.STRING, + "genres": types.SEMICOLON_SPACE_DSV, "style": types.STRING, "discogs_albumid": types.INTEGER, "discogs_artistid": types.INTEGER, @@ -1215,7 +1238,7 @@ class Album(LibModel): "original_day": types.PaddedInt(2), } - _search_fields = ("album", "albumartist", "genre") + _search_fields = ("album", "albumartist", "genres") _types = { "path": PathType(), @@ -1237,7 +1260,7 @@ class Album(LibModel): "albumartist_credit", "albumartists_credit", "album", - "genre", + "genres", "style", "discogs_albumid", "discogs_artistid", @@ -1293,10 +1316,12 @@ def relation_join(cls) -> str: def _getters(cls): # In addition to plugin-provided computed fields, also expose # the album's directory as `path`. - getters = plugins.album_field_getters() - getters["path"] = Album.item_dir - getters["albumtotal"] = Album._albumtotal - return getters + return { + **super()._getters(), + **plugins.album_field_getters(), + "path": Album.item_dir, + "albumtotal": Album._albumtotal, + } def items(self): """Return an iterable over the items associated with this diff --git a/docs/changelog.rst b/docs/changelog.rst index 54d0855990..49d04f5f84 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -129,6 +129,8 @@ New features: * Beets now uses ``platformdirs`` to determine the default music directory. This location varies between systems -- for example, users can configure it on Unix systems via ``user-dirs.dirs(5)``. +* New multi-valued ``genres`` tag. This change brings up the ``genres`` tag to the same state as the ``*artists*`` multi-valued tags (see :bug:`4743` for details). + :bug:`5426` Bug fixes: diff --git a/test/test_library.py b/test/test_library.py index b5e6d4eebc..e27f3af2eb 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -66,15 +66,19 @@ def test_store_changes_database_value(self): assert new_year == 1987 def test_store_only_writes_dirty_fields(self): - original_genre = self.i.genre - self.i._values_fixed["genre"] = "beatboxing" # change w/o dirtying + original_artist = self.i.artist + self.i._values_fixed["artist"] = "beatboxing" # 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 ( + ( + self.lib._connection() + .execute( + "select artist from items where title = ?", (self.i.title,) + ) + .fetchone()["artist"] + ) + == original_artist ) - assert new_genre == original_genre def test_store_clears_dirty_flags(self): self.i.composer = "tvp" diff --git a/test/test_sort.py b/test/test_sort.py index d6aa5c518b..f08f39b46d 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -33,7 +33,7 @@ def setUp(self): albums = [ Album( album="Album A", - genre="Rock", + label="Label", year=2001, flex1="Flex1-1", flex2="Flex2-A", @@ -41,7 +41,7 @@ def setUp(self): ), Album( album="Album B", - genre="Rock", + label="Label", year=2001, flex1="Flex1-2", flex2="Flex2-A", @@ -49,7 +49,7 @@ def setUp(self): ), Album( album="Album C", - genre="Jazz", + label="Records", year=2005, flex1="Flex1-1", flex2="Flex2-B", @@ -236,19 +236,19 @@ def test_sort_desc(self): def test_sort_two_field_asc(self): q = "" - s1 = dbcore.query.FixedFieldSort("genre", True) + s1 = dbcore.query.FixedFieldSort("label", 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]["label"] <= results[1]["label"] + assert results[1]["label"] <= results[2]["label"] + assert results[1]["label"] == "Label" + assert results[2]["label"] == "Records" assert results[1]["album"] <= results[2]["album"] # same thing with query string - q = "genre+ album+" + q = "label+ album+" results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): assert r1.id == r2.id @@ -388,7 +388,7 @@ def setUp(self): album = Album( album="album", - genre="alternative", + label="label", year="2001", flex1="flex1", flex2="flex2-A", diff --git a/test/test_ui.py b/test/test_ui.py index 6c23b321fc..fcce316351 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -690,7 +690,7 @@ def test_selective_modified_album_metadata_not_moved(self): mf.album = "differentAlbum" mf.genre = "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" @@ -1445,7 +1445,7 @@ def test_completion(self): assert tester.returncode == 0 assert out == b"completion tests passed\n", ( "test/test_completion.sh did not execute properly. " - f'Output:{out.decode("utf-8")}' + f"Output:{out.decode('utf-8')}" )