From 8a97fba2ab4df90da870fe09471bcf4c128e9ae3 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/18] Fix ReplacePlugin CLI command --- beetsplug/replace.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/beetsplug/replace.py b/beetsplug/replace.py index b585a13c1..d6557fc0c 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -10,6 +10,8 @@ from beets import ui, util from beets.plugins import BeetsPlugin if TYPE_CHECKING: + import optparse + from beets.library import Item, Library @@ -21,7 +23,7 @@ class ReplacePlugin(BeetsPlugin): 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 ") @@ -61,7 +63,7 @@ class ReplacePlugin(BeetsPlugin): 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 eaec52bd79202a34ca88ffa063f269bb0e83f86e 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/18] 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 d6557fc0c..e63e4e0e6 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -123,7 +123,14 @@ class ReplacePlugin(BeetsPlugin): 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 a247e317a..7ae9b332c 100644 --- a/test/plugins/test_replace.py +++ b/test/plugins/test_replace.py @@ -5,46 +5,54 @@ import pytest 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) - @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 opus_file(self, tmp_path) -> Path: + dest = tmp_path / "full.opus" + src = Path(_common.RSRC.decode()) / "full.opus" + shutil.copyfile(src, dest) + + return dest + + @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 TestReplace: 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 5586a1677968971ebc04d186f1150adf525f2750 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/18] 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 7216f8399..931b018af 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 651da617c796f9781abb92238184198015d28e20 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/18] 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 7ae9b332c..6554699b3 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 @@ class TestReplace: return dest @pytest.fixture - def library(self) -> Library: + def library(self) -> Generator[Library]: helper = TestHelper() helper.setup_beets() From 2d0c48757e54f5d4828e602ce890e15209661988 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/18] 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 e63e4e0e6..532cc3d5b 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -23,7 +23,9 @@ class ReplacePlugin(BeetsPlugin): 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 ") @@ -61,7 +63,7 @@ class ReplacePlugin(BeetsPlugin): 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): @@ -86,7 +88,7 @@ class ReplacePlugin(BeetsPlugin): 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 6554699b3..d287c6a4a 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 @@ class TestReplace: 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 d460139b45186e82d00dbc3dd261e9118cd6087b 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/18] 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 532cc3d5b..0c9eb29cd 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -125,9 +125,8 @@ class ReplacePlugin(BeetsPlugin): 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: @@ -135,4 +134,7 @@ class ReplacePlugin(BeetsPlugin): 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 99c7820605be8161c9907a98c685e39d9613d158 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/18] Test running replace command --- beetsplug/replace.py | 8 ++-- test/plugins/test_replace.py | 77 ++++++++++++++++++++++++++---------- 2 files changed, 61 insertions(+), 24 deletions(-) diff --git a/beetsplug/replace.py b/beetsplug/replace.py index 0c9eb29cd..f7a091783 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -7,6 +7,8 @@ from typing import TYPE_CHECKING import mediafile from beets import ui, util +from beets.library import Item, Library +from beets.library.exceptions import FileOperationError from beets.plugins import BeetsPlugin if TYPE_CHECKING: @@ -113,7 +115,7 @@ class ReplacePlugin(BeetsPlugin): 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 ( @@ -122,7 +124,7 @@ class ReplacePlugin(BeetsPlugin): ): 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. @@ -131,7 +133,7 @@ class ReplacePlugin(BeetsPlugin): # 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 d287c6a4a..11c862c9e 100644 --- a/test/plugins/test_replace.py +++ b/test/plugins/test_replace.py @@ -2,6 +2,7 @@ import optparse 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 @@ from beetsplug.replace import ReplacePlugin 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 @@ class TestReplace: 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 @@ class TestReplace: 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(self, monkeypatch, mp3_file, opus_file, library): - def always(x): - return lambda *args, **kwargs: x + def test_run_replace_no_matches(self, library): + with pytest.raises(ui.UserError): + replace.run(library, optparse.Values(), ["BBB", ""]) + def test_run_replace_no_song_selected( + 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)) - - mediafile = MediaFile(mp3_file) - mediafile.title = "BBB" - mediafile.save() + monkeypatch.setattr(replace, "select_song", always(None)) item = Item.from_path(mp3_file) library.add(item) - monkeypatch.setattr( - replace, "select_song", lambda *args, **kwargs: item - ) + replace.run(library, optparse.Values(), ["AAA", str(opus_file)]) - replace.run(library, optparse.Values(), ["BBB", 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, "confirm_replacement", always(False)) + + 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", always(item)) + + replace.run(library, optparse.Values(), ["AAA", 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 @@ class TestReplace: # 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 53efa0378587c90c9be222a7d8d8acebd94aaf82 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/18] 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 f7a091783..ea303437b 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -129,6 +129,7 @@ class ReplacePlugin(BeetsPlugin): # 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: @@ -136,7 +137,4 @@ class ReplacePlugin(BeetsPlugin): 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 29e22f992eeaa7a6ead6871837040fd261731eee 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/18] 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 11c862c9e..11d7d85cf 100644 --- a/test/plugins/test_replace.py +++ b/test/plugins/test_replace.py @@ -9,6 +9,7 @@ from mediafile import MediaFile 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 TestReplace: 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 a6c78b56183650c5c55cfec331a582b57950ce48 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/18] Update changelog --- docs/changelog.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1ac16a8e2..f34bf9dd3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -284,10 +284,11 @@ Bug fixes won't crash beets anymore. If you want to raise exceptions instead, set the new configuration option ``raise_on_error`` to ``yes`` :bug:`5903`, :bug:`4789`. +- :doc:`/plugins/replace`: Fixed the command failing to run, and now syncs + metadata in the database with the newly swapped-in file. :bug:`6260` For plugin developers ~~~~~~~~~~~~~~~~~~~~~ - - A new plugin event, ``album_matched``, is sent when an album that is being imported has been matched to its metadata and the corresponding distance has been calculated. From 56750ffe88dcc993f29e4b22611abab8b52b9ad6 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/18] 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 ea303437b..162a9a2e5 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -101,12 +101,8 @@ class ReplacePlugin(BeetsPlugin): 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.""" @@ -128,7 +124,7 @@ class ReplacePlugin(BeetsPlugin): 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 11d7d85cf..44d386aec 100644 --- a/test/plugins/test_replace.py +++ b/test/plugins/test_replace.py @@ -175,7 +175,7 @@ class TestReplace: 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 TestReplace: 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 3717c296c7756f62ae1f543886d876ba5bea8328 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/18] 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 162a9a2e5..8e8cfcfbc 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -125,12 +125,8 @@ class ReplacePlugin(BeetsPlugin): # 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 44d386aec..0df6fc903 100644 --- a/test/plugins/test_replace.py +++ b/test/plugins/test_replace.py @@ -11,7 +11,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 beets.test.helper import TestHelper, capture_log from beetsplug.replace import ReplacePlugin replace = ReplacePlugin() @@ -223,9 +223,12 @@ class TestReplace: 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 @@ class TestReplace: 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 c06ec269a2bbd56d8c28f5b05201cc2d1bd77591 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/18] Move some imports into type-checking blocks --- test/plugins/test_replace.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/plugins/test_replace.py b/test/plugins/test_replace.py index 0df6fc903..fb4d5952f 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 f28a9f0881d51416b13ba6b1483b6e454ad6797f 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/18] 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 8e8cfcfbc..a7b746d5d 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -102,7 +102,9 @@ class ReplacePlugin(BeetsPlugin): 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.""" From 4dd66eefe8c66944fc46a41e9e05c83a77804e0e Mon Sep 17 00:00:00 2001 From: Will Burden <25230174+willburden@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:15:12 +0000 Subject: [PATCH 15/18] Use util function for song selection --- beetsplug/replace.py | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/beetsplug/replace.py b/beetsplug/replace.py index a7b746d5d..08266b73c 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -71,24 +71,19 @@ class ReplacePlugin(BeetsPlugin): for i, item in enumerate(items, 1): ui.print_(f"{i}. {util.displayable_path(item)}") - while True: - try: - index = int( - input( - f"Which song would you like to replace? " - f"[1-{len(items)}] (0 to cancel): " - ) - ) - if index == 0: - return None - if 1 <= index <= len(items): - return items[index - 1] - ui.print_( - f"Invalid choice. Please enter a number " - f"between 1 and {len(items)}." - ) - except ValueError: - ui.print_("Invalid input. Please type in a number.") + index = ui.input_options( + [], + require=True, + prompt=( + f"Which song would you like to replace? " + f"[1-{len(items)}] (0 to cancel):" + ), + numrange=(0, len(items)), + ) + + if index == 0: + return None + return items[index - 1] def confirm_replacement(self, new_file_path: Path, song: Item) -> bool: """Get user confirmation for the replacement.""" From 15f3be8797f245774f5bba7c9609ab237ac51e77 Mon Sep 17 00:00:00 2001 From: Will Burden <25230174+willburden@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:29:50 +0000 Subject: [PATCH 16/18] Replace docs more specific about metadata syncing --- docs/plugins/replace.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/plugins/replace.rst b/docs/plugins/replace.rst index 931b018af..7ec9f3ec8 100644 --- a/docs/plugins/replace.rst +++ b/docs/plugins/replace.rst @@ -15,8 +15,10 @@ 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. +The file you pick will be replaced with the file at `path`. Then, the new file's metadata +will be synced with the database. This means that the tags in the database for that track +(`title`, `artist`, etc.) will be written to the new file, and the `path` and `mtime` fields +in the database will be updated to match the new file's path and the current modification time. Consider using the ``replaygain`` command from the :doc:`/plugins/replaygain` plugin, if you usually use it during imports. From 28322cad15bda1296a12823a1f1d409f9341c9a3 Mon Sep 17 00:00:00 2001 From: Will Burden <25230174+willburden@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:53:45 +0000 Subject: [PATCH 17/18] Use Mock instead of 'always' function --- beetsplug/replace.py | 1 - test/plugins/test_replace.py | 64 +++++++++++++++--------------------- 2 files changed, 27 insertions(+), 38 deletions(-) diff --git a/beetsplug/replace.py b/beetsplug/replace.py index 08266b73c..75e6d1d21 100644 --- a/beetsplug/replace.py +++ b/beetsplug/replace.py @@ -8,7 +8,6 @@ import mediafile from beets import ui, util from beets.library import Item, Library -from beets.library.exceptions import FileOperationError from beets.plugins import BeetsPlugin if TYPE_CHECKING: diff --git a/test/plugins/test_replace.py b/test/plugins/test_replace.py index fb4d5952f..3d4f96e50 100644 --- a/test/plugins/test_replace.py +++ b/test/plugins/test_replace.py @@ -24,17 +24,6 @@ if TYPE_CHECKING: replace = ReplacePlugin() -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: @@ -76,8 +65,8 @@ class TestReplace: 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)) + monkeypatch.setattr(replace, "file_check", Mock(return_value=None)) + monkeypatch.setattr(replace, "select_song", Mock(return_value=None)) item = Item.from_path(mp3_file) library.add(item) @@ -90,13 +79,15 @@ class TestReplace: def test_run_replace_not_confirmed( self, library, mp3_file, opus_file, monkeypatch ): - monkeypatch.setattr(replace, "file_check", always(None)) - monkeypatch.setattr(replace, "confirm_replacement", always(False)) + monkeypatch.setattr(replace, "file_check", Mock(return_value=None)) + monkeypatch.setattr( + replace, "confirm_replacement", Mock(return_value=False) + ) item = Item.from_path(mp3_file) library.add(item) - monkeypatch.setattr(replace, "select_song", always(item)) + monkeypatch.setattr(replace, "select_song", Mock(return_value=item)) replace.run(library, optparse.Values(), ["AAA", str(opus_file)]) @@ -107,13 +98,15 @@ class TestReplace: 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)) + monkeypatch.setattr(replace, "file_check", Mock(return_value=None)) + monkeypatch.setattr( + replace, "confirm_replacement", Mock(return_value=True) + ) item = Item.from_path(mp3_file) library.add(item) - monkeypatch.setattr(replace, "select_song", always(item)) + monkeypatch.setattr(replace, "select_song", Mock(return_value=item)) replace.run(library, optparse.Values(), ["AAA", str(opus_file)]) @@ -136,7 +129,7 @@ class TestReplace: def test_select_song_valid_choice(self, monkeypatch, capfd): songs = ["Song A", "Song B", "Song C"] - monkeypatch.setattr("builtins.input", lambda _: "2") + monkeypatch.setattr("builtins.input", Mock(return_value="2")) selected_song = replace.select_song(songs) @@ -149,26 +142,23 @@ class TestReplace: def test_select_song_cancel(self, monkeypatch): songs = ["Song A", "Song B", "Song C"] - monkeypatch.setattr("builtins.input", lambda _: "0") + monkeypatch.setattr("builtins.input", Mock(return_value="0")) selected_song = replace.select_song(songs) assert selected_song is None - def test_select_song_invalid_then_valid(self, monkeypatch, capfd): + def test_select_song_invalid_then_valid(self, monkeypatch): songs = ["Song A", "Song B", "Song C"] - inputs = iter(["invalid", "4", "3"]) - monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + inputs = ["invalid", "4", "3"] + mock_input = Mock(side_effect=iter(inputs)) + monkeypatch.setattr("builtins.input", mock_input) selected_song = replace.select_song(songs) - captured = capfd.readouterr() - - assert "Invalid input. Please type in a number." in captured.out - assert ( - "Invalid choice. Please enter a number between 1 and 3." - in captured.out - ) + # The first two inputs should be considered invalid, so the third + # input of 3 should be used, resulting in Song C being selected. + assert mock_input.call_count == 3 assert selected_song == "Song C" def test_confirm_replacement_file_not_exist(self): @@ -182,7 +172,7 @@ class TestReplace: def test_confirm_replacement_yes(self, monkeypatch): src = Path(_common.RSRC.decode()) / "full.mp3" - monkeypatch.setattr("builtins.input", always("yes")) + monkeypatch.setattr("builtins.input", Mock(return_value="yes")) class Song: path = str(src).encode() @@ -193,7 +183,7 @@ class TestReplace: def test_confirm_replacement_no(self, monkeypatch): src = Path(_common.RSRC.decode()) / "full.mp3" - monkeypatch.setattr("builtins.input", always("no")) + monkeypatch.setattr("builtins.input", Mock(return_value="no")) class Song: path = str(src).encode() @@ -212,7 +202,8 @@ class TestReplace: def test_replace_file_delete_fails( self, library, mp3_file, opus_file, monkeypatch ): - monkeypatch.setattr(Path, "unlink", always_raise(OSError)) + fail_unlink = Mock(side_effect=OSError("cannot delete")) + monkeypatch.setattr(Path, "unlink", fail_unlink) item = Item.from_path(mp3_file) library.add(item) @@ -223,9 +214,8 @@ class TestReplace: def test_replace_file_write_fails( self, library, mp3_file, opus_file, monkeypatch ): - monkeypatch.setattr( - Item, "write", always_raise(WriteError("path", "reason")) - ) + fail_write = Mock(side_effect=WriteError("path", "reason")) + monkeypatch.setattr(Item, "write", fail_write) item = Item.from_path(mp3_file) library.add(item) From f23abda908c9026e221b01bdb500a0c514a80fa6 Mon Sep 17 00:00:00 2001 From: Will Burden <25230174+willburden@users.noreply.github.com> Date: Sun, 8 Mar 2026 17:06:16 +0000 Subject: [PATCH 18/18] Format docs --- docs/changelog.rst | 1 + docs/plugins/replace.rst | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f34bf9dd3..d42aa5621 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -289,6 +289,7 @@ Bug fixes For plugin developers ~~~~~~~~~~~~~~~~~~~~~ + - A new plugin event, ``album_matched``, is sent when an album that is being imported has been matched to its metadata and the corresponding distance has been calculated. diff --git a/docs/plugins/replace.rst b/docs/plugins/replace.rst index 7ec9f3ec8..2de6e2936 100644 --- a/docs/plugins/replace.rst +++ b/docs/plugins/replace.rst @@ -15,10 +15,11 @@ 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`. Then, the new file's metadata -will be synced with the database. This means that the tags in the database for that track -(`title`, `artist`, etc.) will be written to the new file, and the `path` and `mtime` fields -in the database will be updated to match the new file's path and the current modification time. +The file you pick will be replaced with the file at ``path``. Then, the new +file's metadata will be synced with the database. This means that the tags in +the database for that track (``title``, ``artist``, etc.) will be written to the +new file, and the ``path`` and ``mtime`` fields in the database will be updated +to match the new file's path and the current modification time. Consider using the ``replaygain`` command from the :doc:`/plugins/replaygain` plugin, if you usually use it during imports.