diff --git a/beetsplug/replace.py b/beetsplug/replace.py new file mode 100644 index 000000000..0c570877b --- /dev/null +++ b/beetsplug/replace.py @@ -0,0 +1,122 @@ +import shutil +from pathlib import Path + +import mediafile + +from beets import ui, util +from beets.library import Item, Library +from beets.plugins import BeetsPlugin + + +class ReplacePlugin(BeetsPlugin): + def commands(self): + cmd = ui.Subcommand( + "replace", help="replace audio file while keeping tags" + ) + cmd.func = self.run + return [cmd] + + def run(self, lib: Library, args: list[str]) -> None: + if len(args) < 2: + raise ui.UserError("Usage: beet replace ") + + new_file_path: Path = Path(args[-1]) + item_query: list[str] = args[:-1] + + self.file_check(new_file_path) + + item_list = list(lib.items(item_query)) + + if not item_list: + raise ui.UserError("No matching songs found.") + + song = self.select_song(item_list) + + if not song: + ui.print_("Operation cancelled.") + return + + if not self.confirm_replacement(new_file_path, song): + ui.print_("Aborting replacement.") + return + + self.replace_file(new_file_path, song) + + def file_check(self, filepath: Path) -> None: + """Check if the file exists and is supported""" + if not filepath.is_file(): + raise ui.UserError( + f"'{util.displayable_path(filepath)}' is not a valid file." + ) + + try: + mediafile.MediaFile(util.syspath(filepath)) + except mediafile.FileTypeError as fte: + raise ui.UserError(fte) + + def select_song(self, items: list[Item]): + """Present a menu of matching songs and get user selection.""" + ui.print_("\nMatching songs:") + 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.") + + def confirm_replacement(self, new_file_path: Path, song: Item): + """Get user confirmation for the replacement.""" + original_file_path: Path = Path(song.path.decode()) + + if not original_file_path.exists(): + raise ui.UserError("The original song file was not found.") + + ui.print_( + 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"} + + def replace_file(self, new_file_path: Path, song: Item) -> None: + """Replace the existing file with the new one.""" + original_file_path = Path(song.path.decode()) + dest = original_file_path.with_suffix(new_file_path.suffix) + + try: + shutil.move(util.syspath(new_file_path), util.syspath(dest)) + except Exception as e: + raise ui.UserError(f"Error replacing file: {e}") + + if ( + new_file_path.suffix != original_file_path.suffix + and original_file_path.exists() + ): + try: + original_file_path.unlink() + except Exception as e: + raise ui.UserError(f"Could not delete original file: {e}") + + song.path = str(dest).encode() + song.store() + + ui.print_("Replacement successful.") diff --git a/docs/changelog.rst b/docs/changelog.rst index 9bc065419..688717351 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,6 +22,7 @@ New features: * :doc:`plugins/discogs`: Implement ``track_for_id`` method to allow retrieving singletons by their Discogs ID. :bug:`4661` +* :doc:`plugins/replace`: Add new plugin. Bug fixes: diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 82fa94281..5fbe42d9f 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -125,6 +125,7 @@ following to your configuration: playlist plexupdate random + replace replaygain rewrite scrub diff --git a/docs/plugins/replace.rst b/docs/plugins/replace.rst new file mode 100644 index 000000000..8695d492c --- /dev/null +++ b/docs/plugins/replace.rst @@ -0,0 +1,17 @@ +Replace Plugin +============== + +The ``replace`` plugin provides a command that replaces the audio file +of a track, while keeping the name and tags intact. It should save +some time when you get the wrong version of a song. + +Enable the ``replace`` plugin in your configuration (see :ref:`using-plugins`) +and then type:: + + $ beet replace + +The plugin will show you a list of files for you to pick from, and then +ask for confirmation. + +Consider using the `replaygain` command from the +:doc:`/plugins/replaygain` plugin, if you usually use it during imports. diff --git a/test/plugins/test_replace.py b/test/plugins/test_replace.py new file mode 100644 index 000000000..a247e317a --- /dev/null +++ b/test/plugins/test_replace.py @@ -0,0 +1,115 @@ +import shutil +from pathlib import Path + +import pytest +from mediafile import MediaFile + +from beets import ui +from beets.test import _common +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(autouse=True) + def _fake_file(self, tmp_path): + self.fake_file = tmp_path + + def test_path_is_dir(self): + fake_directory = self.fake_dir / "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" + 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_select_song_valid_choice(self, monkeypatch, capfd): + songs = ["Song A", "Song B", "Song C"] + monkeypatch.setattr("builtins.input", lambda _: "2") + + selected_song = replace.select_song(songs) + + captured = capfd.readouterr() + + assert "1. Song A" in captured.out + assert "2. Song B" in captured.out + assert "3. Song C" in captured.out + assert selected_song == "Song B" + + def test_select_song_cancel(self, monkeypatch): + songs = ["Song A", "Song B", "Song C"] + monkeypatch.setattr("builtins.input", lambda _: "0") + + selected_song = replace.select_song(songs) + + assert selected_song is None + + def test_select_song_invalid_then_valid(self, monkeypatch, capfd): + songs = ["Song A", "Song B", "Song C"] + inputs = iter(["invalid", "4", "3"]) + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + + 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 + ) + assert selected_song == "Song C" + + def test_confirm_replacement_file_not_exist(self): + class Song: + path = b"test123321.txt" + + song = Song() + + with pytest.raises(ui.UserError): + replace.confirm_replacement("test", song) + + def test_confirm_replacement_yes(self, monkeypatch): + src = Path(_common.RSRC.decode()) / "full.mp3" + monkeypatch.setattr("builtins.input", lambda _: "YES ") + + class Song: + path = str(src).encode() + + song = Song() + + assert replace.confirm_replacement("test", song) is True + + def test_confirm_replacement_no(self, monkeypatch): + src = Path(_common.RSRC.decode()) / "full.mp3" + monkeypatch.setattr("builtins.input", lambda _: "test123") + + class Song: + path = str(src).encode() + + song = Song() + + assert replace.confirm_replacement("test", song) is False