mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 00:24:25 +01:00
Feat: Add replace plugin (#5644)
Adds replace plugin. The plugin allows the user to replace the audio file of a song, while keeping the tags and file name. Some music servers keep track of favourite songs via paths and tags. Now there won't be a need to 'refavourite'. Plus, this skips the import/merge steps.
This commit is contained in:
parent
da5ec00aaf
commit
dd2f203090
5 changed files with 256 additions and 0 deletions
122
beetsplug/replace.py
Normal file
122
beetsplug/replace.py
Normal file
|
|
@ -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 <query> <new_file_path>")
|
||||
|
||||
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.")
|
||||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ following to your configuration:
|
|||
playlist
|
||||
plexupdate
|
||||
random
|
||||
replace
|
||||
replaygain
|
||||
rewrite
|
||||
scrub
|
||||
|
|
|
|||
17
docs/plugins/replace.rst
Normal file
17
docs/plugins/replace.rst
Normal file
|
|
@ -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 <query> <path>
|
||||
|
||||
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.
|
||||
115
test/plugins/test_replace.py
Normal file
115
test/plugins/test_replace.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue