Repository: beetbox/beets

Issue:
Album flexible attributes modifications are not inherited to tracks
As discussed in https://github.com/beetbox/beets/discussions/4742, changing flexible attributes via a `beet modify -a` command does not inherit the changes to the tracks of the album. 

Changing a fixed attribute on an album _does_ inherit the changes to the album-tracks though.

### Problem

Running

```sh
$ beet mod -a flex_attr=new_value search term
```

does not change `flex_attr` on the album`s tracks,

while running

```sh
$ beet mod -a genre=new_value search term  # genre is a fixed attribute available to albums and album-tracks
```

_does_ change `genre` on the album's tracks.


### Proposed solution

- Streamline the behaviour of `beet modify -a` - Fixed attributes changes as well as flexible attributes changes should be inherited to album-tracks.
- Add a CLI option that prevents inheritance, allowing changing fixed/flex attributes on an album **only**










Discussion:

Resolving patch:
diff --git a/beets/library.py b/beets/library.py
--- a/beets/library.py
+++ b/beets/library.py
@@ -1369,22 +1369,27 @@ def set_art(self, path, copy=True):
 
         plugins.send('art_set', album=self)
 
-    def store(self, fields=None):
+    def store(self, fields=None, inherit=True):
         """Update the database with the album information.
 
-        The album's tracks are also updated.
-
         `fields` represents the fields to be stored. If not specified,
         all fields will be.
+
+        The album's tracks are also updated when the `inherit` flag is enabled.
+        This applies to fixed attributes as well as flexible ones. The `id`
+        attribute of the album will never be inherited.
         """
         # Get modified track fields.
         track_updates = {}
         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)
+            if inherit:
+                if key in self.item_keys:  # is a fixed attribute
+                    track_updates[key] = self[key]
+                elif key not in self:  # is a fixed or a flexible attribute
+                    track_deletes.add(key)
+                elif key != 'id':  # is a flexible attribute
+                    track_updates[key] = self[key]
 
         with self._db.transaction():
             super().store(fields)
@@ -1400,7 +1405,7 @@ def store(self, fields=None):
                             del item[key]
                     item.store()
 
-    def try_sync(self, write, move):
+    def try_sync(self, write, move, inherit=True):
         """Synchronize the album and its items with the database.
         Optionally, also write any new tags into the files and update
         their paths.
@@ -1409,7 +1414,7 @@ def try_sync(self, write, move):
         `move` controls whether files (both audio and album art) are
         moved.
         """
-        self.store()
+        self.store(inherit=inherit)
         for item in self.items():
             item.try_sync(write, move)
 
diff --git a/beets/ui/commands.py b/beets/ui/commands.py
--- a/beets/ui/commands.py
+++ b/beets/ui/commands.py
@@ -1489,7 +1489,7 @@ def show_version(lib, opts, args):
 
 # modify: Declaratively change metadata.
 
-def modify_items(lib, mods, dels, query, write, move, album, confirm):
+def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit):
     """Modifies matching items according to user-specified assignments and
     deletions.
 
@@ -1542,7 +1542,7 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm):
     # Apply changes to database and files
     with lib.transaction():
         for obj in changed:
-            obj.try_sync(write, move)
+            obj.try_sync(write, move, inherit)
 
 
 def print_and_modify(obj, mods, dels):
@@ -1585,7 +1585,8 @@ def modify_func(lib, opts, args):
     if not mods and not dels:
         raise ui.UserError('no modifications specified')
     modify_items(lib, mods, dels, query, ui.should_write(opts.write),
-                 ui.should_move(opts.move), opts.album, not opts.yes)
+                 ui.should_move(opts.move), opts.album, not opts.yes,
+                 opts.inherit)
 
 
 modify_cmd = ui.Subcommand(
@@ -1613,6 +1614,10 @@ def modify_func(lib, opts, args):
     '-y', '--yes', action='store_true',
     help='skip confirmation'
 )
+modify_cmd.parser.add_option(
+    '-I', '--noinherit', action='store_false', dest='inherit', default=True,
+    help="when modifying albums, don't also change item data"
+)
 modify_cmd.func = modify_func
 default_commands.append(modify_cmd)
 
diff --git a/beetsplug/ipfs.py b/beetsplug/ipfs.py
--- a/beetsplug/ipfs.py
+++ b/beetsplug/ipfs.py
@@ -296,4 +296,4 @@ def create_new_album(self, album, tmplib):
         self._log.info("Adding '{0}' to temporary library", album)
         new_album = tmplib.add_album(items)
         new_album.ipfs = album.ipfs
-        new_album.store()
+        new_album.store(inherit=False)


Test patch:
diff --git a/test/test_ipfs.py b/test/test_ipfs.py
--- a/test/test_ipfs.py
+++ b/test/test_ipfs.py
@@ -87,7 +87,7 @@ def mk_test_album(self):
 
         album = self.lib.add_album(items)
         album.ipfs = "QmfM9ic5LJj7V6ecozFx1MkSoaaiq3PXfhJoFvyqzpLXSf"
-        album.store()
+        album.store(inherit=False)
 
         return album
 
diff --git a/test/test_library.py b/test/test_library.py
--- a/test/test_library.py
+++ b/test/test_library.py
@@ -1022,10 +1022,17 @@ def test_albuminfo_change_albumartist_changes_items(self):
         self.assertEqual(i.albumartist, 'myNewArtist')
         self.assertNotEqual(i.artist, 'myNewArtist')
 
+    def test_albuminfo_change_artist_does_change_items(self):
+        ai = self.lib.get_album(self.i)
+        ai.artist = 'myNewArtist'
+        ai.store(inherit=True)
+        i = self.lib.items()[0]
+        self.assertEqual(i.artist, 'myNewArtist')
+
     def test_albuminfo_change_artist_does_not_change_items(self):
         ai = self.lib.get_album(self.i)
         ai.artist = 'myNewArtist'
-        ai.store()
+        ai.store(inherit=False)
         i = self.lib.items()[0]
         self.assertNotEqual(i.artist, 'myNewArtist')