Skip to content

Commit dd2f203

Browse files
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.
1 parent da5ec00 commit dd2f203

File tree

5 files changed

+256
-0
lines changed

5 files changed

+256
-0
lines changed

beetsplug/replace.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import shutil
2+
from pathlib import Path
3+
4+
import mediafile
5+
6+
from beets import ui, util
7+
from beets.library import Item, Library
8+
from beets.plugins import BeetsPlugin
9+
10+
11+
class ReplacePlugin(BeetsPlugin):
12+
def commands(self):
13+
cmd = ui.Subcommand(
14+
"replace", help="replace audio file while keeping tags"
15+
)
16+
cmd.func = self.run
17+
return [cmd]
18+
19+
def run(self, lib: Library, args: list[str]) -> None:
20+
if len(args) < 2:
21+
raise ui.UserError("Usage: beet replace <query> <new_file_path>")
22+
23+
new_file_path: Path = Path(args[-1])
24+
item_query: list[str] = args[:-1]
25+
26+
self.file_check(new_file_path)
27+
28+
item_list = list(lib.items(item_query))
29+
30+
if not item_list:
31+
raise ui.UserError("No matching songs found.")
32+
33+
song = self.select_song(item_list)
34+
35+
if not song:
36+
ui.print_("Operation cancelled.")
37+
return
38+
39+
if not self.confirm_replacement(new_file_path, song):
40+
ui.print_("Aborting replacement.")
41+
return
42+
43+
self.replace_file(new_file_path, song)
44+
45+
def file_check(self, filepath: Path) -> None:
46+
"""Check if the file exists and is supported"""
47+
if not filepath.is_file():
48+
raise ui.UserError(
49+
f"'{util.displayable_path(filepath)}' is not a valid file."
50+
)
51+
52+
try:
53+
mediafile.MediaFile(util.syspath(filepath))
54+
except mediafile.FileTypeError as fte:
55+
raise ui.UserError(fte)
56+
57+
def select_song(self, items: list[Item]):
58+
"""Present a menu of matching songs and get user selection."""
59+
ui.print_("\nMatching songs:")
60+
for i, item in enumerate(items, 1):
61+
ui.print_(f"{i}. {util.displayable_path(item)}")
62+
63+
while True:
64+
try:
65+
index = int(
66+
input(
67+
f"Which song would you like to replace? "
68+
f"[1-{len(items)}] (0 to cancel): "
69+
)
70+
)
71+
if index == 0:
72+
return None
73+
if 1 <= index <= len(items):
74+
return items[index - 1]
75+
ui.print_(
76+
f"Invalid choice. Please enter a number "
77+
f"between 1 and {len(items)}."
78+
)
79+
except ValueError:
80+
ui.print_("Invalid input. Please type in a number.")
81+
82+
def confirm_replacement(self, new_file_path: Path, song: Item):
83+
"""Get user confirmation for the replacement."""
84+
original_file_path: Path = Path(song.path.decode())
85+
86+
if not original_file_path.exists():
87+
raise ui.UserError("The original song file was not found.")
88+
89+
ui.print_(
90+
f"\nReplacing: {util.displayable_path(new_file_path)} "
91+
f"-> {util.displayable_path(original_file_path)}"
92+
)
93+
decision: str = (
94+
input("Are you sure you want to replace this track? (y/N): ")
95+
.strip()
96+
.casefold()
97+
)
98+
return decision in {"yes", "y"}
99+
100+
def replace_file(self, new_file_path: Path, song: Item) -> None:
101+
"""Replace the existing file with the new one."""
102+
original_file_path = Path(song.path.decode())
103+
dest = original_file_path.with_suffix(new_file_path.suffix)
104+
105+
try:
106+
shutil.move(util.syspath(new_file_path), util.syspath(dest))
107+
except Exception as e:
108+
raise ui.UserError(f"Error replacing file: {e}")
109+
110+
if (
111+
new_file_path.suffix != original_file_path.suffix
112+
and original_file_path.exists()
113+
):
114+
try:
115+
original_file_path.unlink()
116+
except Exception as e:
117+
raise ui.UserError(f"Could not delete original file: {e}")
118+
119+
song.path = str(dest).encode()
120+
song.store()
121+
122+
ui.print_("Replacement successful.")

docs/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ New features:
2222
* :doc:`plugins/discogs`: Implement ``track_for_id`` method to allow retrieving
2323
singletons by their Discogs ID.
2424
:bug:`4661`
25+
* :doc:`plugins/replace`: Add new plugin.
2526

2627
Bug fixes:
2728

docs/plugins/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ following to your configuration:
125125
playlist
126126
plexupdate
127127
random
128+
replace
128129
replaygain
129130
rewrite
130131
scrub

docs/plugins/replace.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
Replace Plugin
2+
==============
3+
4+
The ``replace`` plugin provides a command that replaces the audio file
5+
of a track, while keeping the name and tags intact. It should save
6+
some time when you get the wrong version of a song.
7+
8+
Enable the ``replace`` plugin in your configuration (see :ref:`using-plugins`)
9+
and then type::
10+
11+
$ beet replace <query> <path>
12+
13+
The plugin will show you a list of files for you to pick from, and then
14+
ask for confirmation.
15+
16+
Consider using the `replaygain` command from the
17+
:doc:`/plugins/replaygain` plugin, if you usually use it during imports.

test/plugins/test_replace.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import shutil
2+
from pathlib import Path
3+
4+
import pytest
5+
from mediafile import MediaFile
6+
7+
from beets import ui
8+
from beets.test import _common
9+
from beetsplug.replace import ReplacePlugin
10+
11+
replace = ReplacePlugin()
12+
13+
14+
class TestReplace:
15+
@pytest.fixture(autouse=True)
16+
def _fake_dir(self, tmp_path):
17+
self.fake_dir = tmp_path
18+
19+
@pytest.fixture(autouse=True)
20+
def _fake_file(self, tmp_path):
21+
self.fake_file = tmp_path
22+
23+
def test_path_is_dir(self):
24+
fake_directory = self.fake_dir / "fakeDir"
25+
fake_directory.mkdir()
26+
with pytest.raises(ui.UserError):
27+
replace.file_check(fake_directory)
28+
29+
def test_path_is_unsupported_file(self):
30+
fake_file = self.fake_file / "fakefile.txt"
31+
fake_file.write_text("test", encoding="utf-8")
32+
with pytest.raises(ui.UserError):
33+
replace.file_check(fake_file)
34+
35+
def test_path_is_supported_file(self):
36+
dest = self.fake_file / "full.mp3"
37+
src = Path(_common.RSRC.decode()) / "full.mp3"
38+
shutil.copyfile(src, dest)
39+
40+
mediafile = MediaFile(dest)
41+
mediafile.albumartist = "AAA"
42+
mediafile.disctitle = "DDD"
43+
mediafile.genres = ["a", "b", "c"]
44+
mediafile.composer = None
45+
mediafile.save()
46+
47+
replace.file_check(Path(str(dest)))
48+
49+
def test_select_song_valid_choice(self, monkeypatch, capfd):
50+
songs = ["Song A", "Song B", "Song C"]
51+
monkeypatch.setattr("builtins.input", lambda _: "2")
52+
53+
selected_song = replace.select_song(songs)
54+
55+
captured = capfd.readouterr()
56+
57+
assert "1. Song A" in captured.out
58+
assert "2. Song B" in captured.out
59+
assert "3. Song C" in captured.out
60+
assert selected_song == "Song B"
61+
62+
def test_select_song_cancel(self, monkeypatch):
63+
songs = ["Song A", "Song B", "Song C"]
64+
monkeypatch.setattr("builtins.input", lambda _: "0")
65+
66+
selected_song = replace.select_song(songs)
67+
68+
assert selected_song is None
69+
70+
def test_select_song_invalid_then_valid(self, monkeypatch, capfd):
71+
songs = ["Song A", "Song B", "Song C"]
72+
inputs = iter(["invalid", "4", "3"])
73+
monkeypatch.setattr("builtins.input", lambda _: next(inputs))
74+
75+
selected_song = replace.select_song(songs)
76+
77+
captured = capfd.readouterr()
78+
79+
assert "Invalid input. Please type in a number." in captured.out
80+
assert (
81+
"Invalid choice. Please enter a number between 1 and 3."
82+
in captured.out
83+
)
84+
assert selected_song == "Song C"
85+
86+
def test_confirm_replacement_file_not_exist(self):
87+
class Song:
88+
path = b"test123321.txt"
89+
90+
song = Song()
91+
92+
with pytest.raises(ui.UserError):
93+
replace.confirm_replacement("test", song)
94+
95+
def test_confirm_replacement_yes(self, monkeypatch):
96+
src = Path(_common.RSRC.decode()) / "full.mp3"
97+
monkeypatch.setattr("builtins.input", lambda _: "YES ")
98+
99+
class Song:
100+
path = str(src).encode()
101+
102+
song = Song()
103+
104+
assert replace.confirm_replacement("test", song) is True
105+
106+
def test_confirm_replacement_no(self, monkeypatch):
107+
src = Path(_common.RSRC.decode()) / "full.mp3"
108+
monkeypatch.setattr("builtins.input", lambda _: "test123")
109+
110+
class Song:
111+
path = str(src).encode()
112+
113+
song = Song()
114+
115+
assert replace.confirm_replacement("test", song) is False

0 commit comments

Comments
 (0)