From 71fd45fc8a78b80e8b58987670096afde2b891bd Mon Sep 17 00:00:00 2001 From: Sebastian Cao Date: Sat, 27 Jun 2026 15:44:46 +0800 Subject: [PATCH 1/3] fix(modify): parse typed values when selecting objects at the confirmation prompt --- beets/ui/commands/modify.py | 27 +++++++++++++-------------- docs/changelog.rst | 3 +++ test/ui/commands/test_modify.py | 25 ++++++++++++++----------- 3 files changed, 30 insertions(+), 25 deletions(-) diff --git a/beets/ui/commands/modify.py b/beets/ui/commands/modify.py index 18ea099f58..d2be3f562c 100644 --- a/beets/ui/commands/modify.py +++ b/beets/ui/commands/modify.py @@ -69,17 +69,18 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit): # objects. ui.print_(f"Modifying {len(objs)} {'album' if album else 'item'}s.") changed = [] - templates = {} - for key, mod in mods.items(): - templates[key] = functemplate.template(mod.value) + templates = {key: functemplate.template(value) for key, value in mods.items()} + + def parse_mods(obj): + # Parse the raw assignment strings into properly typed values for the + # given object (e.g. dates into timestamps). + return { + key: model_cls._parse(key, obj.evaluate_template(templates[key])) + for key in mods.keys() + } + for obj in objs: - obj_mods = {} - for key, mod in mods.items(): - parsed_value = model_cls._parse( - key, obj.evaluate_template(templates[key]) - ) - obj_mods[key] = mod.apply(obj, key, parsed_value) - if print_and_modify(obj, obj_mods, dels) and obj not in changed: + if print_and_modify(obj, parse_mods(obj), dels) and obj not in changed: changed.append(obj) # Still something to do? @@ -101,7 +102,7 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit): changed = ui.input_select_objects( f"Really modify{extra}", changed, - lambda o: print_and_modify(o, mods, dels), + lambda o: print_and_modify(o, parse_mods(o), dels), ) # Apply changes to database and files @@ -169,9 +170,7 @@ def modify_func(lib, opts, args): ) -modify_cmd = ui.Subcommand( - "modify", help="change metadata fields", aliases=("mod",) -) +modify_cmd = ui.Subcommand("modify", help="change metadata fields", aliases=("mod",)) modify_cmd.parser.add_option( "-m", "--move", diff --git a/docs/changelog.rst b/docs/changelog.rst index c99f7b295d..d99b6f9901 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -64,6 +64,9 @@ Bug fixes longer crashes with a ``TypeError``. Missing values are now grouped together, ordered before present ones when sorting ascending and after them when descending. :bug:`3461` +- ``modify``: Fix a crash when choosing ``select`` at the confirmation prompt + while modifying a non-string field such as ``added``; the per-object value is + now parsed before being applied. :bug:`4880` .. For plugin developers diff --git a/test/ui/commands/test_modify.py b/test/ui/commands/test_modify.py index a74f08a44a..7d2b8ae7cb 100644 --- a/test/ui/commands/test_modify.py +++ b/test/ui/commands/test_modify.py @@ -111,11 +111,17 @@ def test_selective_modify(self): assert len(list(original_items)) == 3 assert len(list(new_items)) == 7 + def test_selective_modify_typed_field(self): + # Regression test for a crash when selecting individual objects to + # modify a non-string (here date) field: the confirmation callback + # used to re-apply the raw string instead of the parsed value. + self.modify_inp(["s", "y"], "added=2002-06-03 00:00:00") + item = self.lib.items().get() + assert item.added == pytest.approx(1023062400, abs=24 * 60 * 60) + def test_modify_formatted(self): for i in range(3): - self.add_item_fixture( - title=f"title{i}", artist="artist", album="album" - ) + self.add_item_fixture(title=f"title{i}", artist="artist", album="album") items = list(self.lib.items()) self.modify("title=${title} - append") for item in items: @@ -165,16 +171,15 @@ def test_album_modify_artists_not_split(self): self.modify("--album", "artists=Charli XCX") for item in self.lib.items(): assert item.artists == ["Charli XCX"], ( - f"artists should be a list with one element, " - f"got {item.artists!r}" + f"artists should be a list with one element, " f"got {item.artists!r}" ) def test_album_modify_genres_not_split(self): self.modify("--album", "genres=Rock") for item in self.lib.items(): - assert item.genres == ["Rock"], ( - f"genres should be a list with one element, got {item.genres!r}" - ) + assert item.genres == [ + "Rock" + ], f"genres should be a list with one element, got {item.genres!r}" # Misc @@ -219,9 +224,7 @@ def test_arg_parsing_colon_query(self): assert mods == {"title": ModifyOperation(None, "newTitle")} def test_arg_parsing_delete(self): - query, _, dels = modify_parse_args( - ["title:oldTitle", "title!"], is_album=False - ) + query, _, dels = modify_parse_args(["title:oldTitle", "title!"], is_album=False) assert query == ["title:oldTitle"] assert dels == ["title"] From 636023627e182c3d8a0982b284cad37ff9b0ce11 Mon Sep 17 00:00:00 2001 From: Sebastian Cao Date: Sun, 28 Jun 2026 21:24:28 +0800 Subject: [PATCH 2/3] format and lint with ruff --- beets/ui/commands/modify.py | 8 ++++++-- test/ui/commands/test_modify.py | 17 +++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/beets/ui/commands/modify.py b/beets/ui/commands/modify.py index d2be3f562c..2a426c2876 100644 --- a/beets/ui/commands/modify.py +++ b/beets/ui/commands/modify.py @@ -69,7 +69,9 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit): # objects. ui.print_(f"Modifying {len(objs)} {'album' if album else 'item'}s.") changed = [] - templates = {key: functemplate.template(value) for key, value in mods.items()} + templates = { + key: functemplate.template(value) for key, value in mods.items() + } def parse_mods(obj): # Parse the raw assignment strings into properly typed values for the @@ -170,7 +172,9 @@ def modify_func(lib, opts, args): ) -modify_cmd = ui.Subcommand("modify", help="change metadata fields", aliases=("mod",)) +modify_cmd = ui.Subcommand( + "modify", help="change metadata fields", aliases=("mod",) +) modify_cmd.parser.add_option( "-m", "--move", diff --git a/test/ui/commands/test_modify.py b/test/ui/commands/test_modify.py index 7d2b8ae7cb..bbd9c167de 100644 --- a/test/ui/commands/test_modify.py +++ b/test/ui/commands/test_modify.py @@ -121,7 +121,9 @@ def test_selective_modify_typed_field(self): def test_modify_formatted(self): for i in range(3): - self.add_item_fixture(title=f"title{i}", artist="artist", album="album") + self.add_item_fixture( + title=f"title{i}", artist="artist", album="album" + ) items = list(self.lib.items()) self.modify("title=${title} - append") for item in items: @@ -171,15 +173,16 @@ def test_album_modify_artists_not_split(self): self.modify("--album", "artists=Charli XCX") for item in self.lib.items(): assert item.artists == ["Charli XCX"], ( - f"artists should be a list with one element, " f"got {item.artists!r}" + f"artists should be a list with one element, " + f"got {item.artists!r}" ) def test_album_modify_genres_not_split(self): self.modify("--album", "genres=Rock") for item in self.lib.items(): - assert item.genres == [ - "Rock" - ], f"genres should be a list with one element, got {item.genres!r}" + assert item.genres == ["Rock"], ( + f"genres should be a list with one element, got {item.genres!r}" + ) # Misc @@ -224,7 +227,9 @@ def test_arg_parsing_colon_query(self): assert mods == {"title": ModifyOperation(None, "newTitle")} def test_arg_parsing_delete(self): - query, _, dels = modify_parse_args(["title:oldTitle", "title!"], is_album=False) + query, _, dels = modify_parse_args( + ["title:oldTitle", "title!"], is_album=False + ) assert query == ["title:oldTitle"] assert dels == ["title"] From 22059fdc4440926ecf840223385ef39369460391 Mon Sep 17 00:00:00 2001 From: Sebastian Cao Date: Mon, 13 Jul 2026 11:46:33 +0800 Subject: [PATCH 3/3] adapt the typed-value parsing to the reworked mods, reuse it at the confirm prompt --- beets/ui/commands/modify.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/beets/ui/commands/modify.py b/beets/ui/commands/modify.py index 2a426c2876..15dbbc581c 100644 --- a/beets/ui/commands/modify.py +++ b/beets/ui/commands/modify.py @@ -69,17 +69,20 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit): # objects. ui.print_(f"Modifying {len(objs)} {'album' if album else 'item'}s.") changed = [] - templates = { - key: functemplate.template(value) for key, value in mods.items() - } + templates = {} + for key, mod in mods.items(): + templates[key] = functemplate.template(mod.value) def parse_mods(obj): # Parse the raw assignment strings into properly typed values for the - # given object (e.g. dates into timestamps). - return { - key: model_cls._parse(key, obj.evaluate_template(templates[key])) - for key in mods.keys() - } + # given object (e.g. dates into timestamps) and apply each mod. + obj_mods = {} + for key, mod in mods.items(): + parsed_value = model_cls._parse( + key, obj.evaluate_template(templates[key]) + ) + obj_mods[key] = mod.apply(obj, key, parsed_value) + return obj_mods for obj in objs: if print_and_modify(obj, parse_mods(obj), dels) and obj not in changed: