mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 08:39:17 +01:00
wip
This commit is contained in:
parent
a1c0ebdeef
commit
916a9021fd
10 changed files with 88 additions and 45 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue