Files
beets/beetsplug/edit.py
2026-07-13 12:29:55 -04:00

562 lines
20 KiB
Python

"""Open metadata information in a text editor to let the user edit it."""
from __future__ import annotations
import codecs
import os
import shlex
import subprocess
from collections import Counter
from tempfile import NamedTemporaryFile
from typing import TYPE_CHECKING, Any, cast
import yaml
from beets import plugins, ui, util
from beets.dbcore import types
from beets.exceptions import UserError
from beets.importer import Action
from beets.library import Album, Item
from beets.ui.commands.utils import do_query
from beets.util import PromptChoice
if TYPE_CHECKING:
from beets.importer import ImportSession, ImportTask
# These "safe" types can avoid the format/parse cycle that most fields go
# through: they are safe to edit with native YAML types.
SAFE_TYPES = (
types.BaseFloat,
types.BaseInteger,
types.Boolean,
types.DelimitedString,
)
# Fixed fields that only exist on Item, not Album (e.g. title, track, path).
# Flexible attributes are deliberately excluded from this set: they aren't
# part of either model's fixed schema, so they can't be told apart by name
# alone and are left for the user to configure correctly.
ITEM_ONLY_FIELDS = Item._field_names - Album._field_names
class ParseError(Exception):
"""The modified file is unreadable. The user should be offered a chance to
fix the error.
"""
def edit(filename, log):
"""Open `filename` in a text editor."""
cmd = shlex.split(util.editor_command())
cmd.append(filename)
log.debug("invoking editor command: {!r}", cmd)
try:
subprocess.call(cmd)
except OSError as exc:
raise UserError(f"could not run editor command {cmd[0]!r}: {exc}")
def dump(arg):
"""Dump a sequence of dictionaries as YAML for editing."""
return yaml.safe_dump_all(arg, allow_unicode=True, default_flow_style=False)
def load(s):
"""Read a sequence of YAML documents back to a list of dictionaries
with string keys.
Can raise a `ParseError`.
"""
try:
out = []
for d in yaml.safe_load_all(s):
if not isinstance(d, dict):
raise ParseError(
f"each entry must be a dictionary; found {type(d).__name__}"
)
# Convert all keys to strings. They started out as strings,
# but the user may have inadvertently messed this up.
out.append({str(k): v for k, v in d.items()})
except yaml.YAMLError as e:
raise ParseError(f"invalid YAML: {e}")
return out
def _safe_value(obj, key, value):
"""Check whether the `value` is safe to represent in YAML and trust as
returned from parsed YAML.
This ensures that values do not change their type when the user edits their
YAML representation.
"""
typ = obj._type(key)
return isinstance(typ, SAFE_TYPES) and isinstance(value, typ.model_type)
def flatten(obj, fields):
"""Represent `obj`, a `dbcore.Model` object, as a dictionary for
serialization. Only include the given `fields` if provided;
otherwise, include everything.
The resulting dictionary's keys are strings and the values are
safely YAML-serializable types.
"""
# Format each value.
d = {}
for key in obj.keys():
value = obj[key]
if _safe_value(obj, key, value):
# A safe value that is faithfully representable in YAML.
d[key] = value
else:
# A value that should be edited as a string.
d[key] = obj.formatted()[key]
# Possibly filter field names.
if fields:
return {k: v for k, v in d.items() if k in fields}
return d
def apply_(obj, data):
"""Set the fields of a `dbcore.Model` object according to a
dictionary.
This is the opposite of `flatten`. The `data` dictionary should have
strings as values.
"""
for key, value in data.items():
if _safe_value(obj, key, value):
# A safe value *stayed* represented as a safe type. Assign it
# directly.
obj[key] = value
else:
# Either the field was stringified originally or the user changed
# it from a safe type to an unsafe one. Parse it as a string.
obj.set_parse(key, str(value))
class EditPlugin(plugins.BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add(
{
# The default fields to edit.
"albumfields": "album albumartist",
"itemfields": "track title artist album",
# Silently ignore any changes to these fields.
"ignore_fields": "id path",
}
)
self.register_listener(
"before_choose_candidate", self.before_choose_candidate_listener
)
def commands(self):
edit_command = ui.Subcommand("edit", help="interactively edit metadata")
edit_command.parser.add_option(
"-f",
"--field",
metavar="FIELD",
action="append",
help="edit this field also",
)
edit_command.parser.add_option(
"--all", action="store_true", dest="all", help="edit all fields"
)
edit_command.parser.add_album_option()
edit_command.func = self._edit_command
return [edit_command]
def _edit_command(self, lib, opts, args):
"""The CLI command function for the `beet edit` command."""
# Get the objects to edit.
items, albums = do_query(lib, args, opts.album, False)
objs = albums if opts.album else items
if not objs:
ui.print_("Nothing to edit.")
return
# Get the fields to edit.
if opts.all:
fields = None
else:
fields = self._get_fields(opts.album, opts.field)
self.edit(opts.album, objs, fields)
def _get_fields(self, album, extra):
"""Get the set of fields to edit."""
# Start with the configured base fields.
if album:
fields = self.config["albumfields"].as_str_seq()
else:
fields = self.config["itemfields"].as_str_seq()
# Add the requested extra fields.
if extra:
fields += extra
# Ensure we always have the `id` field for identification.
fields.append("id")
return set(fields)
def edit(self, album, objs, fields):
"""The core editor function.
- `album`: A flag indicating whether we're editing Items or Albums.
- `objs`: The `Item`s or `Album`s to edit.
- `fields`: The set of field names to edit (or None to edit
everything).
"""
# Present the YAML to the user and let them change it.
success = self.edit_objects(objs, fields)
# Save the new data.
if success:
self.save_changes(objs)
def edit_objects(self, objs, fields):
"""Dump a set of Model objects to a file as text, ask the user
to edit it, and apply any changes to the objects.
Return a boolean indicating whether the edit succeeded.
"""
# Get the content to edit as raw data structures.
old_data = [flatten(o, fields) for o in objs]
cur_str = dump(old_data)
# Loop until we have parseable data and the user confirms.
while True:
result = self._edit_yaml(cur_str)
if result is None:
return False
new_data, new_str = result
# Show the changes.
# If the objects are not on the DB yet, we need a copy of their
# original state for show_model_changes.
objs_old = [obj.copy() if obj.id < 0 else None for obj in objs]
self.apply_data(objs, old_data, new_data)
changed = False
for obj, obj_old in zip(objs, objs_old):
changed |= ui.show_model_changes(obj, obj_old)
if not changed:
ui.print_("No changes to apply.")
return False
# For cancel/keep-editing, restore objects to their original
# in-memory state so temp edits don't leak into the session
choice = ui.input_options(("continue Editing", "apply", "cancel"))
if choice == "a": # Apply.
return True
if choice == "c": # Cancel.
self.apply_data(objs, new_data, old_data)
return False
if choice == "e": # Keep editing.
self.apply_data(objs, new_data, old_data)
cur_str = new_str
continue
def apply_data(self, objs, old_data, new_data):
"""Take potentially-updated data and apply it to a set of Model
objects.
Documents are matched to objects by their ``id`` field rather than
by position, so a reordered or otherwise misaligned document list
cannot cause one object's data to be applied to a different object.
The objects are not written back to the database, so the changes
are temporary.
"""
if len(old_data) != len(new_data):
self._log.warning(
"number of objects changed from {} to {}",
len(old_data),
len(new_data),
)
obj_by_id = {o.id: o for o in objs}
old_by_id = {d.get("id"): d for d in old_data}
new_id_counts = Counter(d.get("id") for d in new_data)
ignore_fields = self.config["ignore_fields"].as_str_seq()
for new_dict in new_data:
new_id = new_dict.get("id")
if new_id_counts[new_id] > 1:
# Two or more documents claim the same id: either a document's
# id was edited to collide with another one, or the same
# document was duplicated. We can't tell which document is
# the "real" one, so ignore all of them.
self._log.warning(
"ignoring objects with duplicate id {}", new_id
)
continue
old_dict = old_by_id.get(new_id)
obj = obj_by_id.get(new_id)
if old_dict is None or obj is None:
self._log.warning("ignoring object whose id changed")
continue
# Prohibit any changes to forbidden fields to avoid
# clobbering `id` and such by mistake.
forbidden = False
for key in ignore_fields:
if old_dict.get(key) != new_dict.get(key):
self._log.warning("ignoring object whose {} changed", key)
forbidden = True
break
if forbidden:
continue
apply_(obj, new_dict)
def save_changes(self, objs):
"""Save a list of updated Model objects to the database."""
# Save to the database and possibly write tags.
for ob in objs:
if ob._dirty:
self._log.debug("saving changes to {}", ob)
ob.try_sync(ui.should_write(), ui.should_move())
# Methods for interactive importer execution.
def before_choose_candidate_listener(self, session, task):
"""Append an "Edit" choice and an "edit Candidates" choice (if
there are candidates) to the interactive importer prompt.
"""
choices = [PromptChoice("d", "eDit", self.importer_edit)]
if task.candidates:
choices.append(
PromptChoice(
"c", "edit Candidates", self.importer_edit_candidate
)
)
return choices
def _importer_edit_album_header(
self, task: ImportTask
) -> dict[str, Any] | None:
"""Build the album-header YAML document for import editing.
Returns a dict of album-level fields, or ``None`` when the current
task is not an album import.
"""
if not getattr(task, "is_album", False) or not task.items:
return None
album_fields = set(self.config["albumfields"].as_str_seq())
if not album_fields:
return None
# Drop fields that only exist on Item (title, track, path, ...);
# since the header is built from a single item and then applied to
# every item, an item-only field here would silently stamp that
# one item's value onto the whole album. Flexible fields are left
# alone so they can still be edited at the album level.
item_only_fields = album_fields & ITEM_ONLY_FIELDS
if item_only_fields:
self._log.warning(
"ignoring item-only fields configured in albumfields: {}",
", ".join(sorted(item_only_fields)),
)
album_fields -= ITEM_ONLY_FIELDS
if not album_fields:
return None
first_item = task.items[0]
header = flatten(first_item, album_fields)
return header if header else None
def _importer_edit_apply_header(
self, items: list[Item], header_data: dict[str, Any]
) -> None:
"""Apply album-header changes to every item in the list."""
if not header_data:
return
for item in items:
apply_(item, header_data)
def _edit_yaml(
self, old_str: str
) -> tuple[list[dict[str, Any]], str] | None:
"""Open a temporary file with `old_str`, let the user edit it, and
return the parsed list of YAML documents.
Returns ``(parsed_data, edited_str)`` on success, or ``None`` if the
user aborted (no changes or unresolvable parse error).
"""
with NamedTemporaryFile(
mode="w", suffix=".yaml", delete=False, encoding="utf-8"
) as new:
new.write(old_str)
try:
while True:
edit(new.name, self._log)
with codecs.open(new.name, encoding="utf-8") as f:
new_str = f.read()
if new_str == old_str:
ui.print_("No changes; aborting.")
return None
try:
return load(new_str), new_str
except ParseError as e:
ui.print_(f"Could not read data: {e}")
if not ui.input_yn("Edit again to fix? (Y/n)", True):
return None
finally:
os.remove(new.name)
def importer_edit(
self, session: ImportSession, task: ImportTask
) -> Action | None:
"""Callback for invoking the functionality during an interactive
import session on the *original* item tags.
"""
# Assign negative temporary ids to Items that are not in the database
# yet. By using negative values, no clash with items in the database
# can occur.
for i, obj in enumerate(task.items, start=1):
# The importer may set the id to None when re-importing albums.
if not obj._db or obj.id is None:
obj.id = -i
# Decide which fields to show.
album_fields = set()
if getattr(task, "is_album", False):
album_fields = set(self.config["albumfields"].as_str_seq())
item_fields = set(self.config["itemfields"].as_str_seq())
# Track-level fields exclude any that are shown in the album header
# to avoid duplication.
track_fields = item_fields - album_fields
track_fields.add("id")
# Build the YAML document list.
header_data = self._importer_edit_album_header(task)
old_track_data = [flatten(o, track_fields) for o in task.items]
all_old_data = []
if header_data is not None:
all_old_data.append(header_data)
all_old_data.extend(old_track_data)
has_header = header_data is not None
num_data_docs = 1 if has_header else 0
cur_str = dump(all_old_data)
while True:
result = self._edit_yaml(cur_str)
if result is None:
self._importer_edit_cleanup(task)
return None
new_all_data, new_str = result
expected_total = num_data_docs + len(task.items)
if len(new_all_data) != expected_total:
ui.print_(
f"Number of documents changed from {expected_total} to "
f"{len(new_all_data)}."
)
if ui.input_yn("Edit again to fix? (Y/n)", True):
continue
self._importer_edit_cleanup(task)
return None
# Split into album header and per-track documents. Every track
# document always has an `id` field (see `track_fields` above),
# while the header never does, so identify the header by the
# absence of `id` rather than by position. This keeps the split
# correct even if the user moves the header elsewhere in the
# file, since `apply_data` already matches track documents by
# id regardless of order.
if has_header:
new_header_data = [d for d in new_all_data if "id" not in d]
new_track_data = [d for d in new_all_data if "id" in d]
if len(new_header_data) != 1:
ui.print_(
"Could not identify the album header: exactly one "
"document must have no `id` field."
)
if ui.input_yn("Edit again to fix? (Y/n)", True):
continue
self._importer_edit_cleanup(task)
return None
else:
new_header_data = []
new_track_data = new_all_data
# Snapshot originals for diff display and restore.
objs_old = cast("list[Item]", [obj.copy() for obj in task.items])
# Apply header changes to every item.
if new_header_data:
self._importer_edit_apply_header(task.items, new_header_data[0])
# Apply per-track changes.
self.apply_data(task.items, old_track_data, new_track_data)
# Show the diff.
changed = False
for item, old_copy in zip(task.items, objs_old):
changed |= ui.show_model_changes(item, old_copy)
if not changed:
ui.print_("No changes to apply.")
self._importer_edit_cleanup(task)
return None
choice = ui.input_options(("continue Editing", "apply", "cancel"))
if choice == "a": # Apply.
self._importer_edit_cleanup(task)
return Action.RETAG
if choice == "c": # Cancel.
self._importer_edit_restore_from_copies(task, objs_old)
self._importer_edit_cleanup(task)
return None
if choice == "e": # Keep editing.
self._importer_edit_restore_from_copies(task, objs_old)
cur_str = new_str
continue
@staticmethod
def _importer_edit_cleanup(task: ImportTask) -> None:
"""Remove temporary negative ids from task items."""
for obj in task.items:
if obj.id is not None and obj.id < 0:
obj.id = None
@staticmethod
def _importer_edit_restore_from_copies(
task: ImportTask, copies: list[Item]
) -> None:
"""Restore items to their state before the last edit cycle.
``copies`` must be a list of :class:`Item <beets.library.Item>` copies
taken *before* the changes were applied.
"""
for i, item in enumerate(task.items):
if i < len(copies):
for key in item._fields:
item[key] = copies[i][key]
def importer_edit_candidate(self, session, task):
"""Callback for invoking the functionality during an interactive
import session on a *candidate*. The candidate's metadata is
applied to the original items.
"""
# Prompt the user for a candidate.
sel = ui.input_options([], numrange=(1, len(task.candidates)))
# Force applying the candidate on the items.
task.match = task.candidates[sel - 1]
task.apply_metadata()
return self.importer_edit(session, task)