This commit is contained in:
Šarūnas Nejus 2025-01-27 11:41:22 +00:00
parent a1c0ebdeef
commit 916a9021fd
No known key found for this signature in database
GPG key ID: DD28F6704DBE3435
10 changed files with 88 additions and 45 deletions

View file

@ -118,7 +118,7 @@ def _apply_metadata(
if value is None and field not in nullable_fields: if value is None and field not in nullable_fields:
continue continue
db_obj[field] = value setattr(db_obj, field, value)
def correct_list_fields(m: LibModel) -> None: def correct_list_fields(m: LibModel) -> None:

View file

@ -100,6 +100,7 @@ class AlbumInfo(AttrDict):
country: str | None = None, country: str | None = None,
style: str | None = None, style: str | None = None,
genre: str | None = None, genre: str | None = None,
genres: str | None = None,
albumstatus: str | None = None, albumstatus: str | None = None,
media: str | None = None, media: str | None = None,
albumdisambig: str | None = None, albumdisambig: str | None = None,
@ -143,6 +144,7 @@ class AlbumInfo(AttrDict):
self.country = country self.country = country
self.style = style self.style = style
self.genre = genre self.genre = genre
self.genres = genres or ([genre] if genre else [])
self.albumstatus = albumstatus self.albumstatus = albumstatus
self.media = media self.media = media
self.albumdisambig = albumdisambig self.albumdisambig = albumdisambig
@ -212,6 +214,7 @@ class TrackInfo(AttrDict):
bpm: str | None = None, bpm: str | None = None,
initial_key: str | None = None, initial_key: str | None = None,
genre: str | None = None, genre: str | None = None,
genres: str | None = None,
album: str | None = None, album: str | None = None,
**kwargs, **kwargs,
): ):
@ -245,7 +248,7 @@ class TrackInfo(AttrDict):
self.work_disambig = work_disambig self.work_disambig = work_disambig
self.bpm = bpm self.bpm = bpm
self.initial_key = initial_key self.initial_key = initial_key
self.genre = genre self.genres = genres
self.album = album self.album = album
self.update(kwargs) self.update(kwargs)

View file

@ -614,10 +614,10 @@ def album_info(release: dict) -> beets.autotag.hooks.AlbumInfo:
for source in sources: for source in sources:
for genreitem in source: for genreitem in source:
genres[genreitem["name"]] += int(genreitem["count"]) genres[genreitem["name"]] += int(genreitem["count"])
info.genre = "; ".join( info.genres = [
genre genre
for genre, _count in sorted(genres.items(), key=lambda g: -g[1]) for genre, _count in sorted(genres.items(), key=lambda g: -g[1])
) ]
# We might find links to external sources (Discogs, Bandcamp, ...) # We might find links to external sources (Discogs, Bandcamp, ...)
external_ids = config["musicbrainz"]["external_ids"].get() external_ids = config["musicbrainz"]["external_ids"].get()
@ -808,7 +808,6 @@ def _merge_pseudo_and_actual_album(
"barcode", "barcode",
"asin", "asin",
"style", "style",
"genre",
] ]
} }
merged.update(from_actual) merged.update(from_actual)

View file

@ -432,7 +432,7 @@ class Model(ABC, Generic[D]):
# Essential field accessors. # Essential field accessors.
@classmethod @classmethod
def _type(cls, key) -> types.Type: def _type(cls, key):
"""Get the type of a field, a `Type` instance. """Get the type of a field, a `Type` instance.
If the field has no explicit type, it is given the base `Type`, If the field has no explicit type, it is given the base `Type`,
@ -528,7 +528,7 @@ class Model(ABC, Generic[D]):
def update(self, values): def update(self, values):
"""Assign all values in the given dict.""" """Assign all values in the given dict."""
for key, value in values.items(): for key, value in values.items():
self[key] = value setattr(self, key, value)
def items(self) -> Iterator[tuple[str, Any]]: def items(self) -> Iterator[tuple[str, Any]]:
"""Iterate over (key, value) pairs that this object contains. """Iterate over (key, value) pairs that this object contains.
@ -559,7 +559,11 @@ class Model(ABC, Generic[D]):
raise AttributeError(f"no such field {key!r}") raise AttributeError(f"no such field {key!r}")
def __setattr__(self, key, value): 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) super().__setattr__(key, value)
else: else:
self[key] = value self[key] = value
@ -714,7 +718,7 @@ class Model(ABC, Generic[D]):
def set_parse(self, key, string: str): def set_parse(self, key, string: str):
"""Set the object's key to a value represented by a string.""" """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. # Database controller and supporting interfaces.

View file

@ -304,7 +304,13 @@ class DelimitedString(BaseString[list[str], list[str]]):
return [] return []
return string.split(self.delimiter) 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) return self.delimiter.join(model_value)

View file

@ -354,10 +354,27 @@ class LibModel(dbcore.Model["Library"]):
def writable_media_fields(cls) -> set[str]: def writable_media_fields(cls) -> set[str]:
return set(MediaFile.fields()) & cls._fields.keys() 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): def _template_funcs(self):
funcs = DefaultTemplateFunctions(self, self._db).functions() return {
funcs.update(plugins.template_funcs()) **DefaultTemplateFunctions(self, self._db).functions(),
return funcs **plugins.template_funcs(),
"genre": "$genres",
}
def store(self, fields=None): def store(self, fields=None):
super().store(fields) super().store(fields)
@ -533,7 +550,7 @@ class Item(LibModel):
"albumartists_sort": types.MULTI_VALUE_DSV, "albumartists_sort": types.MULTI_VALUE_DSV,
"albumartist_credit": types.STRING, "albumartist_credit": types.STRING,
"albumartists_credit": types.MULTI_VALUE_DSV, "albumartists_credit": types.MULTI_VALUE_DSV,
"genre": types.STRING, "genres": types.SEMICOLON_SPACE_DSV,
"style": types.STRING, "style": types.STRING,
"discogs_albumid": types.INTEGER, "discogs_albumid": types.INTEGER,
"discogs_artistid": types.INTEGER, "discogs_artistid": types.INTEGER,
@ -614,7 +631,7 @@ class Item(LibModel):
"comments", "comments",
"album", "album",
"albumartist", "albumartist",
"genre", "genres",
) )
_types = { _types = {
@ -689,10 +706,12 @@ class Item(LibModel):
@classmethod @classmethod
def _getters(cls): def _getters(cls):
getters = plugins.item_field_getters() return {
getters["singleton"] = lambda i: i.album_id is None **plugins.item_field_getters(),
getters["filesize"] = Item.try_filesize # In bytes. "singleton": lambda i: i.album_id is None,
return getters "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: def duplicates_query(self, fields: list[str]) -> dbcore.AndQuery:
"""Return a query for entities with same values in the given fields.""" """Return a query for entities with same values in the given fields."""
@ -768,6 +787,10 @@ class Item(LibModel):
Set `with_album` to false to skip album fallback. 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: try:
return self._get(key, default, raise_=with_album) return self._get(key, default, raise_=with_album)
except KeyError: except KeyError:
@ -1181,7 +1204,7 @@ class Album(LibModel):
"albumartists_sort": types.MULTI_VALUE_DSV, "albumartists_sort": types.MULTI_VALUE_DSV,
"albumartists_credit": types.MULTI_VALUE_DSV, "albumartists_credit": types.MULTI_VALUE_DSV,
"album": types.STRING, "album": types.STRING,
"genre": types.STRING, "genres": types.SEMICOLON_SPACE_DSV,
"style": types.STRING, "style": types.STRING,
"discogs_albumid": types.INTEGER, "discogs_albumid": types.INTEGER,
"discogs_artistid": types.INTEGER, "discogs_artistid": types.INTEGER,
@ -1215,7 +1238,7 @@ class Album(LibModel):
"original_day": types.PaddedInt(2), "original_day": types.PaddedInt(2),
} }
_search_fields = ("album", "albumartist", "genre") _search_fields = ("album", "albumartist", "genres")
_types = { _types = {
"path": PathType(), "path": PathType(),
@ -1237,7 +1260,7 @@ class Album(LibModel):
"albumartist_credit", "albumartist_credit",
"albumartists_credit", "albumartists_credit",
"album", "album",
"genre", "genres",
"style", "style",
"discogs_albumid", "discogs_albumid",
"discogs_artistid", "discogs_artistid",
@ -1293,10 +1316,12 @@ class Album(LibModel):
def _getters(cls): def _getters(cls):
# In addition to plugin-provided computed fields, also expose # In addition to plugin-provided computed fields, also expose
# the album's directory as `path`. # the album's directory as `path`.
getters = plugins.album_field_getters() return {
getters["path"] = Album.item_dir **super()._getters(),
getters["albumtotal"] = Album._albumtotal **plugins.album_field_getters(),
return getters "path": Album.item_dir,
"albumtotal": Album._albumtotal,
}
def items(self): def items(self):
"""Return an iterable over the items associated with this """Return an iterable over the items associated with this

View file

@ -129,6 +129,8 @@ New features:
* Beets now uses ``platformdirs`` to determine the default music directory. * Beets now uses ``platformdirs`` to determine the default music directory.
This location varies between systems -- for example, users can configure it This location varies between systems -- for example, users can configure it
on Unix systems via ``user-dirs.dirs(5)``. 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: Bug fixes:

View file

@ -66,15 +66,19 @@ class StoreTest(ItemInDBTestCase):
assert new_year == 1987 assert new_year == 1987
def test_store_only_writes_dirty_fields(self): def test_store_only_writes_dirty_fields(self):
original_genre = self.i.genre original_artist = self.i.artist
self.i._values_fixed["genre"] = "beatboxing" # change w/o dirtying self.i._values_fixed["artist"] = "beatboxing" # change w/o dirtying
self.i.store() self.i.store()
new_genre = ( assert (
self.lib._connection() (
.execute("select genre from items where title = ?", (self.i.title,)) self.lib._connection()
.fetchone()["genre"] .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): def test_store_clears_dirty_flags(self):
self.i.composer = "tvp" self.i.composer = "tvp"

View file

@ -33,7 +33,7 @@ class DummyDataTestCase(BeetsTestCase):
albums = [ albums = [
Album( Album(
album="Album A", album="Album A",
genre="Rock", label="Label",
year=2001, year=2001,
flex1="Flex1-1", flex1="Flex1-1",
flex2="Flex2-A", flex2="Flex2-A",
@ -41,7 +41,7 @@ class DummyDataTestCase(BeetsTestCase):
), ),
Album( Album(
album="Album B", album="Album B",
genre="Rock", label="Label",
year=2001, year=2001,
flex1="Flex1-2", flex1="Flex1-2",
flex2="Flex2-A", flex2="Flex2-A",
@ -49,7 +49,7 @@ class DummyDataTestCase(BeetsTestCase):
), ),
Album( Album(
album="Album C", album="Album C",
genre="Jazz", label="Records",
year=2005, year=2005,
flex1="Flex1-1", flex1="Flex1-1",
flex2="Flex2-B", flex2="Flex2-B",
@ -236,19 +236,19 @@ class SortAlbumFixedFieldTest(DummyDataTestCase):
def test_sort_two_field_asc(self): def test_sort_two_field_asc(self):
q = "" q = ""
s1 = dbcore.query.FixedFieldSort("genre", True) s1 = dbcore.query.FixedFieldSort("label", True)
s2 = dbcore.query.FixedFieldSort("album", True) s2 = dbcore.query.FixedFieldSort("album", True)
sort = dbcore.query.MultipleSort() sort = dbcore.query.MultipleSort()
sort.add_sort(s1) sort.add_sort(s1)
sort.add_sort(s2) sort.add_sort(s2)
results = self.lib.albums(q, sort) results = self.lib.albums(q, sort)
assert results[0]["genre"] <= results[1]["genre"] assert results[0]["label"] <= results[1]["label"]
assert results[1]["genre"] <= results[2]["genre"] assert results[1]["label"] <= results[2]["label"]
assert results[1]["genre"] == "Rock" assert results[1]["label"] == "Label"
assert results[2]["genre"] == "Rock" assert results[2]["label"] == "Records"
assert results[1]["album"] <= results[2]["album"] assert results[1]["album"] <= results[2]["album"]
# same thing with query string # same thing with query string
q = "genre+ album+" q = "label+ album+"
results2 = self.lib.albums(q) results2 = self.lib.albums(q)
for r1, r2 in zip(results, results2): for r1, r2 in zip(results, results2):
assert r1.id == r2.id assert r1.id == r2.id
@ -388,7 +388,7 @@ class CaseSensitivityTest(DummyDataTestCase, BeetsTestCase):
album = Album( album = Album(
album="album", album="album",
genre="alternative", label="label",
year="2001", year="2001",
flex1="flex1", flex1="flex1",
flex2="flex2-A", flex2="flex2-A",

View file

@ -690,7 +690,7 @@ class UpdateTest(BeetsTestCase):
mf.album = "differentAlbum" mf.album = "differentAlbum"
mf.genre = "differentGenre" mf.genre = "differentGenre"
mf.save() mf.save()
self._update(move=True, fields=["genre"]) self._update(move=True, fields=["genres"])
item = self.lib.items().get() item = self.lib.items().get()
assert b"differentAlbum" not in item.path assert b"differentAlbum" not in item.path
assert item.genre == "differentGenre" assert item.genre == "differentGenre"
@ -1445,7 +1445,7 @@ class CompletionTest(TestPluginTestCase):
assert tester.returncode == 0 assert tester.returncode == 0
assert out == b"completion tests passed\n", ( assert out == b"completion tests passed\n", (
"test/test_completion.sh did not execute properly. " "test/test_completion.sh did not execute properly. "
f'Output:{out.decode("utf-8")}' f"Output:{out.decode('utf-8')}"
) )