Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 34 additions & 33 deletions beetsplug/replace.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
import mediafile

from beets import ui, util
from beets.library import Item, Library
from beets.plugins import BeetsPlugin

if TYPE_CHECKING:
import optparse

from beets.library import Item, Library


Expand All @@ -21,7 +24,9 @@ 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 <query> <new_file_path>")

Expand Down Expand Up @@ -59,32 +64,27 @@ 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_("\nMatching songs:")
ui.print_("Matching 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):
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."""
original_file_path: Path = Path(song.path.decode())

Expand All @@ -95,12 +95,10 @@ def confirm_replacement(self, new_file_path: Path, song: Item):
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 ui.input_yn(
"Are you sure you want to replace this track (y/n)?", require=True
)
return decision in {"yes", "y"}

def replace_file(self, new_file_path: Path, song: Item) -> None:
"""Replace the existing file with the new one."""
Expand All @@ -109,7 +107,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}")
Comment on lines 108 to 111
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

grug worry shutil.move into dest that already exist (common when suffix same) break on Windows (rename fail). codebase already have util.move using os.replace + safe fallback. use util.move(..., replace=True) (convert paths to bytes via util.bytestring_path) so replace always overwrite cross-platform.

Copilot uses AI. Check for mistakes.

if (
Expand All @@ -118,10 +116,13 @@ 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}")

song.path = str(dest).encode()
song.store()
# Update the path to point to the new file.
song.path = util.bytestring_path(dest)

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)
2 changes: 2 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,8 @@ 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
~~~~~~~~~~~~~~~~~~~~~
Expand Down
6 changes: 6 additions & 0 deletions docs/plugins/replace.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +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.

Consider using the ``replaygain`` command from the :doc:`/plugins/replaygain`
plugin, if you usually use it during imports.
Loading
Loading