Repository: beetbox/beets

Issue:
Removing a flexible field from an album doesn't remove corresponding field from its items
<!--
Describe your problem, feature request, or discussion topic here.

Please fill out this and the "Setup" section below and remember to include
enough detail so that other people can reproduce the problem.
-->

### Problem

When importing an album with the `--set` flag, beets imports the album with "flex1" set on both the album and each of its tracks:

```sh
$ beet import <path> --set flex1=something
```

Later, when deleting the flex field with `-a`, the value is updated on the album, but is still set on the items:

```sh
$ beet modify -a "Some album" flex1!
```

After modifying, search for matching tracks, and they still return

```sh
$ beet ls flex1::something
```
```
Some artist - Some album - Track name 1
Some artist - Some album - Track name 2
...
```

Search for matching albums, returns nothing

```sh
$ beet ls -a flex1::something
```
```sh
# no results
```

### Setup

* OS: Arch
* Python version: 3.10.9
* beets version: 1.6.1
* Turning off plugins made problem go away (yes/no): no

<!--
You can turn off plugins temporarily by passing --plugins= on the command line:

$ beet --plugins= version
-->

My configuration (output of `beet config`) is:

```
(tested with default settings)
```

### Extra info

Also of note, this issue naturally doesn't come up if the flex field was added to just the album after import using `beet mod -a flex1=something` which is how I configured a lot of my library, so this issue only happens to albums imported with `import --set`

Discussion:

Patch:
diff --git a/beets/library.py b/beets/library.py
index 981563974..d62403cbf 100644
--- a/beets/library.py
+++ b/beets/library.py
@@ -1355,9 +1355,12 @@ class Album(LibModel):
         """
         # Get modified track fields.
         track_updates = {}
-        for key in self.item_keys:
-            if key in self._dirty:
+        track_deletes = set()
+        for key in self._dirty:
+            if key in self.item_keys:
                 track_updates[key] = self[key]
+            elif key not in self:
+                track_deletes.add(key)
 
         with self._db.transaction():
             super().store(fields)
@@ -1366,6 +1369,12 @@ class Album(LibModel):
                     for key, value in track_updates.items():
                         item[key] = value
                     item.store()
+            if track_deletes:
+                for item in self.items():
+                    for key in track_deletes:
+                        if key in item:
+                            del item[key]
+                    item.store()
 
     def try_sync(self, write, move):
         """Synchronize the album and its items with the database.
diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py
index 103aa1107..a474871ac 100644
--- a/beetsplug/discogs.py
+++ b/beetsplug/discogs.py
@@ -180,44 +180,6 @@ class DiscogsPlugin(BeetsPlugin):
             self._log.debug('Connection error in album search', exc_info=True)
             return []
 
-    def item_candidates(self, item, artist, title):
-        """Returns a list of TrackInfo objects for Search API results
-        matching ``title`` and ``artist``.
-        :param item: Singleton item to be matched.
-        :type item: beets.library.Item
-        :param artist: The artist of the track to be matched.
-        :type artist: str
-        :param title: The title of the track to be matched.
-        :type title: str
-        :return: Candidate TrackInfo objects.
-        :rtype: list[beets.autotag.hooks.TrackInfo]
-        """
-        if not self.discogs_client:
-            return
-
-        if not artist and not title:
-            self._log.debug('Skipping Discogs query. File missing artist and '
-                            'title tags.')
-
-        query = f'{artist} {title}'
-        try:
-            albums = self.get_albums(query)
-        except DiscogsAPIError as e:
-            self._log.debug('API Error: {0} (query: {1})', e, query)
-            if e.status_code == 401:
-                self.reset_auth()
-                return self.item_candidates(item, artist, title)
-            else:
-                return []
-        except CONNECTION_ERRORS:
-            self._log.debug('Connection error in track search', exc_info=True)
-        candidates = []
-        for album_cur in albums:
-            self._log.debug(u'searching within album {0}', album_cur.album)
-            candidates += album_cur.tracks
-        # first 10 results, don't overwhelm with options
-        return candidates[:10]
-
     @staticmethod
     def extract_release_id_regex(album_id):
         """Returns the Discogs_id or None."""
@@ -389,15 +351,9 @@ class DiscogsPlugin(BeetsPlugin):
         for track in tracks:
             track.media = media
             track.medium_total = mediums.count(track.medium)
-            if not track.artist:  # get_track_info often fails to find artist
-                track.artist = artist
-            if not track.artist_id:
-                track.artist_id = artist_id
             # Discogs does not have track IDs. Invent our own IDs as proposed
             # in #2336.
             track.track_id = str(album_id) + "-" + track.track_alt
-            track.data_url = data_url
-            track.data_source = 'Discogs'
 
         # Retrieve master release id (returns None if there isn't one).
         master_id = result.data.get('master_id')
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 9587788fa..f39282633 100755
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -36,8 +36,7 @@ New features:
 * Add :ref:`exact match <exact-match>` queries, using the prefixes ``=`` and
   ``=~``.
   :bug:`4251`
-* :doc:`/plugins/discogs`: Permit appending style to genre.
-* :doc:`plugins/discogs`: Implement item_candidates for matching singletons.
+* :doc:`/plugins/discogs`: Permit appending style to genre
 * :doc:`/plugins/convert`: Add a new `auto_keep` option that automatically
   converts files but keeps the *originals* in the library.
   :bug:`1840` :bug:`4302`
@@ -134,6 +133,8 @@ Bug fixes:
 * :doc:`/plugins/fromfilename`: Fix failed detection of <track> <title>
   filename patterns.
   :bug:`4561` :bug:`4600`
+* Fix issue where deletion of flexible fields on an album doesn't cascade to items
+  :bug:`4662`
 
 For packagers:
 
diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst
index 1203a9ca3..17a1645bb 100644
--- a/docs/plugins/discogs.rst
+++ b/docs/plugins/discogs.rst
@@ -2,7 +2,8 @@ Discogs Plugin
 ==============
 
 The ``discogs`` plugin extends the autotagger's search capabilities to
-include matches from the `Discogs`_ database.
+include matches from the `Discogs`_ database when importing albums.
+(The plugin does not yet support matching singleton tracks.)
 
 .. _Discogs: https://discogs.com
 
@@ -117,7 +118,8 @@ Here are two things you can try:
 * Make sure that your system clock is accurate. The Discogs servers can reject
   your request if your clock is too out of sync.
 
-Matching tracks by Discogs ID is not yet supported. The ``--group-albums``
-option in album import mode provides an alternative to singleton mode for autotagging tracks that are not in album-related folders.
+The plugin can only match albums, so no Discogs matches will be
+reported when importing singletons using ``-s``. One possible
+workaround is to use the ``--group-albums`` option.
 
 .. _python3-discogs-client: https://github.com/joalla/discogs_client


Test patch:
diff --git a/test/test_library.py b/test/test_library.py
index 5e57afb17..389f3fa5e 100644
--- a/test/test_library.py
+++ b/test/test_library.py
@@ -77,6 +77,19 @@ class StoreTest(_common.LibTestCase):
         self.i.store()
         self.assertTrue('composer' not in self.i._dirty)
 
+    def test_store_album_cascades_flex_deletes(self):
+        album = _common.album()
+        album.flex1 = "Flex-1"
+        self.lib.add(album)
+        item = _common.item()
+        item.album_id = album.id
+        item.flex1 = "Flex-1"
+        self.lib.add(item)
+        del album.flex1
+        album.store()
+        self.assertNotIn('flex1', album)
+        self.assertNotIn('flex1', album.items()[0])
+
 
 class AddTest(_common.TestCase):
     def setUp(self):