From b17ffe7f63b0c84c26e816842770e696fafcf5f0 Mon Sep 17 00:00:00 2001 From: Will Burden <25230174+willburden@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:28:11 +0000 Subject: [PATCH 01/14] Fix ReplacePlugin CLI command --- beetsplug/replace.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/beetsplug/replace.py b/beetsplug/replace.py index 0c570877b8..44c22e5577 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -1,3 +1,4 @@ +import optparse import shutil from pathlib import Path @@ -16,7 +17,7 @@ def commands(self): cmd.func = self.run return [cmd] - def run(self, lib: Library, args: list[str]) -> None: + def run(self, lib: Library, opts: optparse.Values, args: list[str]) -> None: if len(args) < 2: raise ui.UserError("Usage: beet replace ") @@ -56,7 +57,7 @@ def file_check(self, filepath: Path) -> None: def select_song(self, items: list[Item]): """Present a menu of matching songs and get user selection.""" - ui.print_("\nMatching songs:") + ui.print_("Matching songs:") for i, item in enumerate(items, 1): ui.print_(f"{i}. {util.displayable_path(item)}") From 1a240312ebedcd13347e5be2287f5c2fab8502d2 Mon Sep 17 00:00:00 2001 From: Will Burden <25230174+willburden@users.noreply.github.com> Date: Fri, 12 Dec 2025 22:37:15 +0000 Subject: [PATCH 02/14] Replace command now writes metadata to the file --- beetsplug/replace.py | 7 ++++ test/plugins/test_replace.py | 81 ++++++++++++++++++++++++++---------- 2 files changed, 65 insertions(+), 23 deletions(-) diff --git a/beetsplug/replace.py b/beetsplug/replace.py index 44c22e5577..c30dd741a5 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -117,7 +117,14 @@ def replace_file(self, new_file_path: Path, song: Item) -> None: except Exception as e: raise ui.UserError(f"Could not delete original file: {e}") + # Store the new path in the database. song.path = str(dest).encode() song.store() + # Write the metadata in the database to the song file's tags. + try: + song.write() + except Exception as e: + raise ui.UserError(f"Error writing metadata to file: {e}") + ui.print_("Replacement successful.") diff --git a/test/plugins/test_replace.py b/test/plugins/test_replace.py index a247e317ad..7ae9b332ce 100644 --- a/test/plugins/test_replace.py +++ b/test/plugins/test_replace.py @@ -5,46 +5,54 @@ from mediafile import MediaFile from beets import ui +from beets.library import Item, Library from beets.test import _common +from beets.test.helper import TestHelper from beetsplug.replace import ReplacePlugin replace = ReplacePlugin() class TestReplace: - @pytest.fixture(autouse=True) - def _fake_dir(self, tmp_path): - self.fake_dir = tmp_path + @pytest.fixture + def mp3_file(self, tmp_path) -> Path: + dest = tmp_path / "full.mp3" + src = Path(_common.RSRC.decode()) / "full.mp3" + shutil.copyfile(src, dest) + + return dest + + @pytest.fixture + def opus_file(self, tmp_path) -> Path: + dest = tmp_path / "full.opus" + src = Path(_common.RSRC.decode()) / "full.opus" + shutil.copyfile(src, dest) - @pytest.fixture(autouse=True) - def _fake_file(self, tmp_path): - self.fake_file = tmp_path + return dest - def test_path_is_dir(self): - fake_directory = self.fake_dir / "fakeDir" + @pytest.fixture + def library(self) -> Library: + helper = TestHelper() + helper.setup_beets() + + yield helper.lib + + helper.teardown_beets() + + def test_path_is_dir(self, tmp_path): + fake_directory = tmp_path / "fakeDir" fake_directory.mkdir() with pytest.raises(ui.UserError): replace.file_check(fake_directory) - def test_path_is_unsupported_file(self): - fake_file = self.fake_file / "fakefile.txt" + def test_path_is_unsupported_file(self, tmp_path): + fake_file = tmp_path / "fakefile.txt" fake_file.write_text("test", encoding="utf-8") with pytest.raises(ui.UserError): replace.file_check(fake_file) - def test_path_is_supported_file(self): - dest = self.fake_file / "full.mp3" - src = Path(_common.RSRC.decode()) / "full.mp3" - shutil.copyfile(src, dest) - - mediafile = MediaFile(dest) - mediafile.albumartist = "AAA" - mediafile.disctitle = "DDD" - mediafile.genres = ["a", "b", "c"] - mediafile.composer = None - mediafile.save() - - replace.file_check(Path(str(dest))) + def test_path_is_supported_file(self, mp3_file): + replace.file_check(mp3_file) def test_select_song_valid_choice(self, monkeypatch, capfd): songs = ["Song A", "Song B", "Song C"] @@ -113,3 +121,30 @@ class Song: song = Song() assert replace.confirm_replacement("test", song) is False + + def test_replace_file( + self, mp3_file: Path, opus_file: Path, library: Library + ): + old_mediafile = MediaFile(mp3_file) + old_mediafile.albumartist = "ABC" + old_mediafile.disctitle = "DEF" + old_mediafile.genre = "GHI" + old_mediafile.save() + + item = Item.from_path(mp3_file) + library.add(item) + + replace.replace_file(opus_file, item) + + # Check that the file has been replaced. + assert opus_file.exists() + assert not mp3_file.exists() + + # Check that the database path has been updated. + assert item.path == bytes(opus_file) + + # Check that the new file has the old file's metadata. + new_mediafile = MediaFile(opus_file) + assert new_mediafile.albumartist == old_mediafile.albumartist + assert new_mediafile.disctitle == old_mediafile.disctitle + assert new_mediafile.genre == old_mediafile.genre From 0c033a3e0a8910ddacfa864f7162f248790da2cb Mon Sep 17 00:00:00 2001 From: Will Burden <25230174+willburden@users.noreply.github.com> Date: Fri, 12 Dec 2025 23:12:19 +0000 Subject: [PATCH 03/14] Update replace plugin docs --- docs/plugins/replace.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/plugins/replace.rst b/docs/plugins/replace.rst index 7216f83991..931b018afb 100644 --- a/docs/plugins/replace.rst +++ b/docs/plugins/replace.rst @@ -15,5 +15,8 @@ and then type: The plugin will show you a list of files for you to pick from, and then ask for confirmation. +The file you pick will be replaced with the file at `path`, and the tags in the +database will be written to that file's metadata. + Consider using the ``replaygain`` command from the :doc:`/plugins/replaygain` plugin, if you usually use it during imports. From e783561aeee7ee1fd567d6fa1c94cf4c98848431 Mon Sep 17 00:00:00 2001 From: Will Burden <25230174+willburden@users.noreply.github.com> Date: Fri, 12 Dec 2025 23:33:54 +0000 Subject: [PATCH 04/14] Fix fixture return type --- test/plugins/test_replace.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/plugins/test_replace.py b/test/plugins/test_replace.py index 7ae9b332ce..6554699b3f 100644 --- a/test/plugins/test_replace.py +++ b/test/plugins/test_replace.py @@ -1,4 +1,5 @@ import shutil +from collections.abc import Generator from pathlib import Path import pytest @@ -31,7 +32,7 @@ def opus_file(self, tmp_path) -> Path: return dest @pytest.fixture - def library(self) -> Library: + def library(self) -> Generator[Library]: helper = TestHelper() helper.setup_beets() From 6b168de0cd8b824e409acaa3acc3e415513dc543 Mon Sep 17 00:00:00 2001 From: Will Burden <25230174+willburden@users.noreply.github.com> Date: Sat, 13 Dec 2025 00:00:11 +0000 Subject: [PATCH 05/14] Add tests for replace CLI command --- beetsplug/replace.py | 8 +++++--- test/plugins/test_replace.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/beetsplug/replace.py b/beetsplug/replace.py index c30dd741a5..564fadbdc2 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -17,7 +17,9 @@ def commands(self): cmd.func = self.run return [cmd] - def run(self, lib: Library, opts: optparse.Values, args: list[str]) -> None: + def run( + self, lib: Library, _opts: optparse.Values, args: list[str] + ) -> None: if len(args) < 2: raise ui.UserError("Usage: beet replace ") @@ -55,7 +57,7 @@ def file_check(self, filepath: Path) -> None: except mediafile.FileTypeError as fte: raise ui.UserError(fte) - def select_song(self, items: list[Item]): + def select_song(self, items: list[Item]) -> Item | None: """Present a menu of matching songs and get user selection.""" ui.print_("Matching songs:") for i, item in enumerate(items, 1): @@ -80,7 +82,7 @@ def select_song(self, items: list[Item]): except ValueError: ui.print_("Invalid input. Please type in a number.") - def confirm_replacement(self, new_file_path: Path, song: Item): + def confirm_replacement(self, new_file_path: Path, song: Item) -> bool: """Get user confirmation for the replacement.""" original_file_path: Path = Path(song.path.decode()) diff --git a/test/plugins/test_replace.py b/test/plugins/test_replace.py index 6554699b3f..d287c6a4aa 100644 --- a/test/plugins/test_replace.py +++ b/test/plugins/test_replace.py @@ -1,3 +1,4 @@ +import optparse import shutil from collections.abc import Generator from pathlib import Path @@ -40,6 +41,34 @@ def library(self) -> Generator[Library]: helper.teardown_beets() + def test_run_replace_with_too_few_args(self): + with pytest.raises(ui.UserError) as excinfo: + replace.run(None, optparse.Values(), []) + + # Ensure we get a usage-style error message + assert "Usage" in str(excinfo.value) + + def test_run_replace(self, monkeypatch, mp3_file, opus_file, library): + def always(x): + return lambda *args, **kwargs: x + + monkeypatch.setattr(replace, "file_check", always(None)) + monkeypatch.setattr(replace, "replace_file", always(None)) + monkeypatch.setattr(replace, "confirm_replacement", always(True)) + + mediafile = MediaFile(mp3_file) + mediafile.title = "BBB" + mediafile.save() + + item = Item.from_path(mp3_file) + library.add(item) + + monkeypatch.setattr( + replace, "select_song", lambda *args, **kwargs: item + ) + + replace.run(library, optparse.Values(), ["BBB", str(opus_file)]) + def test_path_is_dir(self, tmp_path): fake_directory = tmp_path / "fakeDir" fake_directory.mkdir() From 92a482ab91aa5b1fa530f3c6c2cc392fa8d4ee27 Mon Sep 17 00:00:00 2001 From: Will Burden <25230174+willburden@users.noreply.github.com> Date: Sat, 13 Dec 2025 00:14:14 +0000 Subject: [PATCH 06/14] Store new path only after successful write --- beetsplug/replace.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/beetsplug/replace.py b/beetsplug/replace.py index 564fadbdc2..809b15a195 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -119,9 +119,8 @@ def replace_file(self, new_file_path: Path, song: Item) -> None: except Exception as e: raise ui.UserError(f"Could not delete original file: {e}") - # Store the new path in the database. + # Update the path to point to the new file. song.path = str(dest).encode() - song.store() # Write the metadata in the database to the song file's tags. try: @@ -129,4 +128,7 @@ def replace_file(self, new_file_path: Path, song: Item) -> None: except Exception as e: raise ui.UserError(f"Error writing metadata to file: {e}") + # Commit the new path to the database. + song.store() + ui.print_("Replacement successful.") From a08bfda4b219e2309ee495531e9949ba26f94a1d Mon Sep 17 00:00:00 2001 From: Will Burden <25230174+willburden@users.noreply.github.com> Date: Sat, 13 Dec 2025 14:38:01 +0000 Subject: [PATCH 07/14] Test running replace command --- beetsplug/replace.py | 7 ++-- test/plugins/test_replace.py | 73 ++++++++++++++++++++++++++---------- 2 files changed, 58 insertions(+), 22 deletions(-) diff --git a/beetsplug/replace.py b/beetsplug/replace.py index 809b15a195..3d12f669e6 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -6,6 +6,7 @@ from beets import ui, util from beets.library import Item, Library +from beets.library.exceptions import FileOperationError from beets.plugins import BeetsPlugin @@ -107,7 +108,7 @@ def replace_file(self, new_file_path: Path, song: Item) -> None: try: shutil.move(util.syspath(new_file_path), util.syspath(dest)) - except Exception as e: + except OSError as e: raise ui.UserError(f"Error replacing file: {e}") if ( @@ -116,7 +117,7 @@ def replace_file(self, new_file_path: Path, song: Item) -> None: ): try: original_file_path.unlink() - except Exception as e: + except OSError as e: raise ui.UserError(f"Could not delete original file: {e}") # Update the path to point to the new file. @@ -125,7 +126,7 @@ def replace_file(self, new_file_path: Path, song: Item) -> None: # Write the metadata in the database to the song file's tags. try: song.write() - except Exception as e: + except FileOperationError as e: raise ui.UserError(f"Error writing metadata to file: {e}") # Commit the new path to the database. diff --git a/test/plugins/test_replace.py b/test/plugins/test_replace.py index d287c6a4aa..11c862c9ee 100644 --- a/test/plugins/test_replace.py +++ b/test/plugins/test_replace.py @@ -2,6 +2,7 @@ import shutil from collections.abc import Generator from pathlib import Path +from unittest.mock import Mock import pytest from mediafile import MediaFile @@ -15,6 +16,10 @@ replace = ReplacePlugin() +def always(x): + return lambda *args, **kwargs: x + + class TestReplace: @pytest.fixture def mp3_file(self, tmp_path) -> Path: @@ -22,6 +27,10 @@ def mp3_file(self, tmp_path) -> Path: src = Path(_common.RSRC.decode()) / "full.mp3" shutil.copyfile(src, dest) + mediafile = MediaFile(dest) + mediafile.title = "AAA" + mediafile.save() + return dest @pytest.fixture @@ -41,33 +50,59 @@ def library(self) -> Generator[Library]: helper.teardown_beets() - def test_run_replace_with_too_few_args(self): - with pytest.raises(ui.UserError) as excinfo: + def test_run_replace_too_few_args(self): + with pytest.raises(ui.UserError): replace.run(None, optparse.Values(), []) - # Ensure we get a usage-style error message - assert "Usage" in str(excinfo.value) + def test_run_replace_no_matches(self, library): + with pytest.raises(ui.UserError): + replace.run(library, optparse.Values(), ["BBB", ""]) - def test_run_replace(self, monkeypatch, mp3_file, opus_file, library): - def always(x): - return lambda *args, **kwargs: x + def test_run_replace_no_song_selected( + self, library, mp3_file, opus_file, monkeypatch + ): + monkeypatch.setattr(replace, "file_check", always(None)) + monkeypatch.setattr(replace, "select_song", always(None)) + item = Item.from_path(mp3_file) + library.add(item) + + replace.run(library, optparse.Values(), ["AAA", str(opus_file)]) + + assert mp3_file.exists() + assert opus_file.exists() + + def test_run_replace_not_confirmed( + self, library, mp3_file, opus_file, monkeypatch + ): monkeypatch.setattr(replace, "file_check", always(None)) - monkeypatch.setattr(replace, "replace_file", always(None)) - monkeypatch.setattr(replace, "confirm_replacement", always(True)) + monkeypatch.setattr(replace, "confirm_replacement", always(False)) - mediafile = MediaFile(mp3_file) - mediafile.title = "BBB" - mediafile.save() + item = Item.from_path(mp3_file) + library.add(item) + + monkeypatch.setattr(replace, "select_song", always(item)) + + replace.run(library, optparse.Values(), ["AAA", str(opus_file)]) + + assert mp3_file.exists() + assert opus_file.exists() + + def test_run_replace(self, library, mp3_file, opus_file, monkeypatch): + replace_file = Mock(replace.replace_file, return_value=None) + monkeypatch.setattr(replace, "replace_file", replace_file) + + monkeypatch.setattr(replace, "file_check", always(None)) + monkeypatch.setattr(replace, "confirm_replacement", always(True)) item = Item.from_path(mp3_file) library.add(item) - monkeypatch.setattr( - replace, "select_song", lambda *args, **kwargs: item - ) + monkeypatch.setattr(replace, "select_song", always(item)) + + replace.run(library, optparse.Values(), ["AAA", str(opus_file)]) - replace.run(library, optparse.Values(), ["BBB", str(opus_file)]) + replace_file.assert_called_once() def test_path_is_dir(self, tmp_path): fake_directory = tmp_path / "fakeDir" @@ -175,6 +210,6 @@ def test_replace_file( # Check that the new file has the old file's metadata. new_mediafile = MediaFile(opus_file) - assert new_mediafile.albumartist == old_mediafile.albumartist - assert new_mediafile.disctitle == old_mediafile.disctitle - assert new_mediafile.genre == old_mediafile.genre + assert new_mediafile.albumartist == "ABC" + assert new_mediafile.disctitle == "DEF" + assert new_mediafile.genre == "GHI" From 84452fcc8b0c34e55588a4cae911b0705f6b3092 Mon Sep 17 00:00:00 2001 From: Will Burden <25230174+willburden@users.noreply.github.com> Date: Sat, 13 Dec 2025 14:53:11 +0000 Subject: [PATCH 08/14] Save new path before writing metadata --- beetsplug/replace.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/beetsplug/replace.py b/beetsplug/replace.py index 3d12f669e6..e1ceceec83 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -122,6 +122,7 @@ def replace_file(self, new_file_path: Path, song: Item) -> None: # Update the path to point to the new file. song.path = str(dest).encode() + song.store() # Write the metadata in the database to the song file's tags. try: @@ -129,7 +130,4 @@ def replace_file(self, new_file_path: Path, song: Item) -> None: except FileOperationError as e: raise ui.UserError(f"Error writing metadata to file: {e}") - # Commit the new path to the database. - song.store() - ui.print_("Replacement successful.") From 9551bae71c596baff14d014b0eed5a5de4eacc71 Mon Sep 17 00:00:00 2001 From: Will Burden <25230174+willburden@users.noreply.github.com> Date: Sat, 13 Dec 2025 15:26:11 +0000 Subject: [PATCH 09/14] Tests for replace.replace_file --- test/plugins/test_replace.py | 39 ++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/plugins/test_replace.py b/test/plugins/test_replace.py index 11c862c9ee..11d7d85cff 100644 --- a/test/plugins/test_replace.py +++ b/test/plugins/test_replace.py @@ -9,6 +9,7 @@ from beets import ui from beets.library import Item, Library +from beets.library.exceptions import WriteError from beets.test import _common from beets.test.helper import TestHelper from beetsplug.replace import ReplacePlugin @@ -20,6 +21,13 @@ def always(x): return lambda *args, **kwargs: x +def always_raise(x): + def err(*args, **kwargs): + raise x + + return err + + class TestReplace: @pytest.fixture def mp3_file(self, tmp_path) -> Path: @@ -187,6 +195,37 @@ class Song: assert replace.confirm_replacement("test", song) is False + def test_replace_file_move_fails(self, tmp_path): + item = Item() + item.path = bytes(tmp_path / "not_a_song.mp3") + + with pytest.raises(ui.UserError): + replace.replace_file(tmp_path / "not_a_file.opus", item) + + def test_replace_file_delete_fails( + self, library, mp3_file, opus_file, monkeypatch + ): + monkeypatch.setattr(Path, "unlink", always_raise(OSError)) + + item = Item.from_path(mp3_file) + library.add(item) + + with pytest.raises(ui.UserError): + replace.replace_file(opus_file, item) + + def test_replace_file_write_fails( + self, library, mp3_file, opus_file, monkeypatch + ): + monkeypatch.setattr( + Item, "write", always_raise(WriteError("path", "reason")) + ) + + item = Item.from_path(mp3_file) + library.add(item) + + with pytest.raises(ui.UserError): + replace.replace_file(opus_file, item) + def test_replace_file( self, mp3_file: Path, opus_file: Path, library: Library ): From b930a38fcb6c9ef7be1b7839097394b647f3b5e9 Mon Sep 17 00:00:00 2001 From: Will Burden <25230174+willburden@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:46:16 +0000 Subject: [PATCH 10/14] Update changelog --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2ba9f5cbdb..30ce05256b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -90,6 +90,8 @@ Bug fixes: - :doc:`/plugins/ftintitle`: Fixed artist name splitting to prioritize explicit featuring tokens (feat, ft, featuring) over generic separators (&, and), preventing incorrect splits when both are present. +- :doc:`/plugins/replace`: Fixed the command failing to run, and now writes the + metadata in the database to the newly swapped-in file. :bug:`6260` For plugin developers: From bc67d7ca27459ff52561a3a7391b0856aad41956 Mon Sep 17 00:00:00 2001 From: Will Burden <25230174+willburden@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:30:30 +0000 Subject: [PATCH 11/14] Use util functions for confirmation and path conversion --- beetsplug/replace.py | 10 +++------- test/plugins/test_replace.py | 4 ++-- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/beetsplug/replace.py b/beetsplug/replace.py index e1ceceec83..f674cc27e2 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -94,12 +94,8 @@ def confirm_replacement(self, new_file_path: Path, song: Item) -> bool: f"\nReplacing: {util.displayable_path(new_file_path)} " f"-> {util.displayable_path(original_file_path)}" ) - decision: str = ( - input("Are you sure you want to replace this track? (y/N): ") - .strip() - .casefold() - ) - return decision in {"yes", "y"} + + return ui.input_yn("Are you sure you want to replace this track (y/n)?") def replace_file(self, new_file_path: Path, song: Item) -> None: """Replace the existing file with the new one.""" @@ -121,7 +117,7 @@ def replace_file(self, new_file_path: Path, song: Item) -> None: raise ui.UserError(f"Could not delete original file: {e}") # Update the path to point to the new file. - song.path = str(dest).encode() + song.path = util.bytestring_path(dest) song.store() # Write the metadata in the database to the song file's tags. diff --git a/test/plugins/test_replace.py b/test/plugins/test_replace.py index 11d7d85cff..44d386aec0 100644 --- a/test/plugins/test_replace.py +++ b/test/plugins/test_replace.py @@ -175,7 +175,7 @@ class Song: def test_confirm_replacement_yes(self, monkeypatch): src = Path(_common.RSRC.decode()) / "full.mp3" - monkeypatch.setattr("builtins.input", lambda _: "YES ") + monkeypatch.setattr("builtins.input", always("yes")) class Song: path = str(src).encode() @@ -186,7 +186,7 @@ class Song: def test_confirm_replacement_no(self, monkeypatch): src = Path(_common.RSRC.decode()) / "full.mp3" - monkeypatch.setattr("builtins.input", lambda _: "test123") + monkeypatch.setattr("builtins.input", always("no")) class Song: path = str(src).encode() From bb9adee7b5468d31994b0d0c8071daf47aa91669 Mon Sep 17 00:00:00 2001 From: Will Burden <25230174+willburden@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:10:15 +0000 Subject: [PATCH 12/14] Switch to using try_sync --- beetsplug/replace.py | 12 ++++-------- test/plugins/test_replace.py | 14 +++++++++++--- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/beetsplug/replace.py b/beetsplug/replace.py index f674cc27e2..c127c64ded 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -118,12 +118,8 @@ def replace_file(self, new_file_path: Path, song: Item) -> None: # Update the path to point to the new file. song.path = util.bytestring_path(dest) - song.store() - # Write the metadata in the database to the song file's tags. - try: - song.write() - except FileOperationError as e: - raise ui.UserError(f"Error writing metadata to file: {e}") - - ui.print_("Replacement successful.") + # Synchronise the new file with the database. This copies metadata from the + # Item to the new file (i.e. title, artist, album, etc.), + # and then from the Item to the database (i.e. path and mtime). + song.try_sync(write=True, move=False) diff --git a/test/plugins/test_replace.py b/test/plugins/test_replace.py index 44d386aec0..0df6fc9038 100644 --- a/test/plugins/test_replace.py +++ b/test/plugins/test_replace.py @@ -11,7 +11,7 @@ from beets.library import Item, Library from beets.library.exceptions import WriteError from beets.test import _common -from beets.test.helper import TestHelper +from beets.test.helper import TestHelper, capture_log from beetsplug.replace import ReplacePlugin replace = ReplacePlugin() @@ -223,9 +223,12 @@ def test_replace_file_write_fails( item = Item.from_path(mp3_file) library.add(item) - with pytest.raises(ui.UserError): + with capture_log() as logs: replace.replace_file(opus_file, item) + # Assert that a writing error was logged + assert next(m for m in logs if m.startswith("error writing")) + def test_replace_file( self, mp3_file: Path, opus_file: Path, library: Library ): @@ -238,14 +241,19 @@ def test_replace_file( item = Item.from_path(mp3_file) library.add(item) + item.mtime = 0 + item.store() + replace.replace_file(opus_file, item) # Check that the file has been replaced. assert opus_file.exists() assert not mp3_file.exists() - # Check that the database path has been updated. + # Check that the database path and mtime have been updated. + item.load() assert item.path == bytes(opus_file) + assert item.mtime > 0 # Check that the new file has the old file's metadata. new_mediafile = MediaFile(opus_file) From 8f1c3fc5199c1bd1aff5b3981b7b360f52a03077 Mon Sep 17 00:00:00 2001 From: Will Burden <25230174+willburden@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:27:23 +0000 Subject: [PATCH 13/14] Move some imports into type-checking blocks --- beetsplug/replace.py | 3 ++- test/plugins/test_replace.py | 11 +++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/beetsplug/replace.py b/beetsplug/replace.py index 10ebc8dc6d..80dccf5b68 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -1,6 +1,5 @@ from __future__ import annotations -import optparse import shutil from pathlib import Path from typing import TYPE_CHECKING @@ -12,6 +11,8 @@ from beets.plugins import BeetsPlugin if TYPE_CHECKING: + import optparse + from beets.library import Item, Library diff --git a/test/plugins/test_replace.py b/test/plugins/test_replace.py index 0df6fc9038..fb4d5952fc 100644 --- a/test/plugins/test_replace.py +++ b/test/plugins/test_replace.py @@ -1,19 +1,26 @@ +from __future__ import annotations + import optparse import shutil -from collections.abc import Generator from pathlib import Path +from typing import TYPE_CHECKING from unittest.mock import Mock import pytest from mediafile import MediaFile from beets import ui -from beets.library import Item, Library +from beets.library import Item from beets.library.exceptions import WriteError from beets.test import _common from beets.test.helper import TestHelper, capture_log from beetsplug.replace import ReplacePlugin +if TYPE_CHECKING: + from collections.abc import Generator + + from beets.library import Library + replace = ReplacePlugin() From 6cf344fce89b357f37131e956da2eb36feb50cda Mon Sep 17 00:00:00 2001 From: Will Burden <25230174+willburden@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:58:16 +0000 Subject: [PATCH 14/14] No default for replace confirmation --- beetsplug/replace.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/beetsplug/replace.py b/beetsplug/replace.py index 80dccf5b68..888cf8c9ce 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -101,7 +101,9 @@ def confirm_replacement(self, new_file_path: Path, song: Item) -> bool: f"-> {util.displayable_path(original_file_path)}" ) - return ui.input_yn("Are you sure you want to replace this track (y/n)?") + return ui.input_yn( + "Are you sure you want to replace this track (y/n)?", require=True + ) def replace_file(self, new_file_path: Path, song: Item) -> None: """Replace the existing file with the new one."""