From ede5994c59a986bebed2428f1cac2ad122682141 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= <snejus@protonmail.com>
Date: Mon, 27 Jan 2025 11:38:02 +0000
Subject: [PATCH] wip

---
 beets/autotag/__init__.py |  2 +-
 beets/autotag/hooks.py    |  5 +++-
 beets/autotag/mb.py       |  5 ++--
 beets/dbcore/db.py        | 12 ++++++---
 beets/dbcore/types.py     |  8 +++++-
 beets/library.py          | 57 ++++++++++++++++++++++++++++-----------
 docs/changelog.rst        |  2 ++
 test/test_library.py      | 18 ++++++++-----
 test/test_sort.py         | 20 +++++++-------
 test/test_ui.py           |  4 +--
 10 files changed, 88 insertions(+), 45 deletions(-)

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..904358382e 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')}"
         )