From 9d237d10fc3149e1b851cb1aa5d521bff20e2f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 22 Feb 2026 00:21:09 +0000 Subject: [PATCH 1/4] Fix multi-value delimiter handling in templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use '\␀' as the DB delimiter while formatting lists with '; ' for templates. - Update DelimitedString parsing to accept both separators: * '\␀' for the values from the DB * '; ' for the rest of parsed values (for example `beet modify genres="eletronic; jazz"`) - Refresh %first docs and tests to reflect multi-value field behavior. --- beets/dbcore/types.py | 28 ++++++++++++++++++++-------- beets/library/models.py | 4 ++-- docs/changelog.rst | 10 +++++++--- docs/conf.py | 3 ++- docs/reference/cli.rst | 3 +++ docs/reference/pathformat.rst | 33 ++++++++++++++++++++++++++++----- test/test_library.py | 12 ++++++------ test/test_plugins.py | 3 +-- 8 files changed, 69 insertions(+), 27 deletions(-) diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py index 61336d9ce..3f94afd05 100644 --- a/beets/dbcore/types.py +++ b/beets/dbcore/types.py @@ -288,25 +288,37 @@ class String(BaseString[str, Any]): class DelimitedString(BaseString[list[str], list[str]]): - """A list of Unicode strings, represented in-database by a single string + r"""A list of Unicode strings, represented in-database by a single string containing delimiter-separated values. + + In template evaluation the list is formatted by joining the values with + a fixed '; ' delimiter regardless of the database delimiter. That is because + the '\␀' character used for multi-value fields is mishandled on Windows + as it contains a backslash character. """ model_type = list[str] + fmt_delimiter = "; " - def __init__(self, delimiter: str): - self.delimiter = delimiter + def __init__(self, db_delimiter: str): + self.db_delimiter = db_delimiter def format(self, value: list[str]): - return self.delimiter.join(value) + return self.fmt_delimiter.join(value) def parse(self, string: str): if not string: return [] - return string.split(self.delimiter) + + delimiter = ( + self.db_delimiter + if self.db_delimiter in string + else self.fmt_delimiter + ) + return string.split(delimiter) def to_sql(self, model_value: list[str]): - return self.delimiter.join(model_value) + return self.db_delimiter.join(model_value) class Boolean(Type): @@ -464,7 +476,7 @@ NULL_FLOAT = NullFloat() STRING = String() BOOLEAN = Boolean() DATE = DateType() -SEMICOLON_SPACE_DSV = DelimitedString(delimiter="; ") +SEMICOLON_SPACE_DSV = DelimitedString("; ") # Will set the proper null char in mediafile -MULTI_VALUE_DSV = DelimitedString(delimiter="\\␀") +MULTI_VALUE_DSV = DelimitedString("\\␀") diff --git a/beets/library/models.py b/beets/library/models.py index e26f2ced3..373c07ee3 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -1546,8 +1546,8 @@ class DefaultTemplateFunctions: s: the string count: The number of items included skip: The number of items skipped - sep: the separator. Usually is '; ' (default) or '/ ' - join_str: the string which will join the items, default '; '. + sep: the separator + join_str: the string which will join the items """ skip = int(skip) count = skip + int(count) diff --git a/docs/changelog.rst b/docs/changelog.rst index 8abed1367..eff2e2cc8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -21,9 +21,13 @@ Unreleased For plugin developers ~~~~~~~~~~~~~~~~~~~~~ -.. - Other changes - ~~~~~~~~~~~~~ +Other changes +~~~~~~~~~~~~~ + +- :ref:`modify-cmd`: Use the following separator to delimite multiple field + values: |semicolon_space|. For example ``beet modify albumtypes="album; ep"``. + Previously, ``\␀`` was used as a separator. This applies to fields such as + ``artists``, ``albumtypes`` etc. 2.6.2 (February 22, 2026) ------------------------- diff --git a/docs/conf.py b/docs/conf.py index f66e91645..8a812d159 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -98,7 +98,7 @@ man_pages = [ ] # Global substitutions that can be used anywhere in the documentation. -rst_epilog = """ +rst_epilog = r""" .. |Album| replace:: :class:`~beets.library.models.Album` .. |AlbumInfo| replace:: :class:`beets.autotag.hooks.AlbumInfo` .. |BeetsPlugin| replace:: :class:`beets.plugins.BeetsPlugin` @@ -108,6 +108,7 @@ rst_epilog = """ .. |Library| replace:: :class:`~beets.library.library.Library` .. |Model| replace:: :class:`~beets.dbcore.db.Model` .. |TrackInfo| replace:: :class:`beets.autotag.hooks.TrackInfo` +.. |semicolon_space| replace:: :literal:`; \ ` """ # -- Options for HTML output ------------------------------------------------- diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index c0274553a..15024022b 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -267,6 +267,9 @@ Values can also be *templates*, using the same syntax as :doc:`path formats artist sort name into the artist field for all your tracks, and ``beet modify title='$track $title'`` will add track numbers to their title metadata. +To adjust a multi-valued field, such as ``genres``, separate the values with +|semicolon_space|. For example, ``beet modify genres="rock; pop"``. + The ``-a`` option changes to querying album fields instead of track fields and also enables to operate on albums in addition to the individual tracks. Without this flag, the command will only change *track-level* data, even if all the diff --git a/docs/reference/pathformat.rst b/docs/reference/pathformat.rst index 10dd3ae05..aff48a7c6 100644 --- a/docs/reference/pathformat.rst +++ b/docs/reference/pathformat.rst @@ -75,11 +75,34 @@ These functions are built in to beets: - ``%time{date_time,format}``: Return the date and time in any format accepted by strftime_. For example, to get the year some music was added to your library, use ``%time{$added,%Y}``. -- ``%first{text}``: Returns the first item, separated by ``;`` (a semicolon - followed by a space). You can use ``%first{text,count,skip}``, where ``count`` - is the number of items (default 1) and ``skip`` is number to skip (default 0). - You can also use ``%first{text,count,skip,sep,join}`` where ``sep`` is the - separator, like ``;`` or ``/`` and join is the text to concatenate the items. +- ``%first{text,count,skip,sep,join}``: Extract a subset of items from a + delimited string. Splits ``text`` by ``sep``, skips the first ``skip`` items, + then returns the next ``count`` items joined by ``join``. + + This is especially useful for multi-valued fields like ``artists`` or + ``genres`` where you may only want the first artist or a limited number of + genres in a path. + + Defaults: + + .. + Comically, you need to follow |semicolon_space| with some punctuation to + make sure it gets rendered correctly as '; ' in the docs. + + - **count**: 1, + - **skip**: 0, + - **sep**: |semicolon_space|, + - **join**: |semicolon_space|. + + Examples: + + :: + + %first{$genres} → returns the first genre + %first{$genres,2} → returns the first two genres, joined by "; " + %first{$genres,2,1} → skips the first genre, returns the next two + %first{$genres,2,0, , -> } → splits by space, joins with " -> " + - ``%ifdef{field}``, ``%ifdef{field,truetext}`` or ``%ifdef{field,truetext,falsetext}``: Checks if an flexible attribute ``field`` is defined. If it exists, then return ``truetext`` or ``field`` diff --git a/test/test_library.py b/test/test_library.py index 4acf34746..4df4e4b58 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -688,14 +688,14 @@ class DestinationFunctionTest(BeetsTestCase, PathFormattingMixin): self._assert_dest(b"/base/not_played") def test_first(self): - self.i.genres = "Pop; Rock; Classical Crossover" - self._setf("%first{$genres}") - self._assert_dest(b"/base/Pop") + self.i.albumtypes = ["album", "compilation"] + self._setf("%first{$albumtypes}") + self._assert_dest(b"/base/album") def test_first_skip(self): - self.i.genres = "Pop; Rock; Classical Crossover" - self._setf("%first{$genres,1,2}") - self._assert_dest(b"/base/Classical Crossover") + self.i.albumtype = "album; ep; compilation" + self._setf("%first{$albumtype,1,2}") + self._assert_dest(b"/base/compilation") def test_first_different_sep(self): self._setf("%first{Alice / Bob / Eve,2,0, / , & }") diff --git a/test/test_plugins.py b/test/test_plugins.py index 4786b12b4..9622fb8db 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -96,8 +96,7 @@ class TestPluginRegistration(IOMixin, PluginTestCase): item.add(self.lib) out = self.run_with_output("ls", "-f", "$multi_value") - delimiter = types.MULTI_VALUE_DSV.delimiter - assert out == f"one{delimiter}two{delimiter}three\n" + assert out == "one; two; three\n" class PluginImportTestCase(ImportHelper, PluginTestCase): From 31f79f14a310937a26fb325bfd5fe2c54a93cc25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 22 Feb 2026 01:12:04 +0000 Subject: [PATCH 2/4] Colorize multi-valued field changes distinctly --- beets/ui/__init__.py | 20 ++++++++++++++++++-- docs/changelog.rst | 1 + test/ui/test_field_diff.py | 15 +++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 4c93d66d8..ddd5ee98b 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -1048,6 +1048,18 @@ def print_newline_layout( FLOAT_EPSILON = 0.01 +def _multi_value_diff(field: str, oldset: set[str], newset: set[str]) -> str: + added = newset - oldset + removed = oldset - newset + + parts = [ + f"{field}:", + *(colorize("text_diff_removed", f" - {i}") for i in sorted(removed)), + *(colorize("text_diff_added", f" + {i}") for i in sorted(added)), + ] + return "\n".join(parts) + + def _field_diff( field: str, old: FormattedMapping, new: FormattedMapping ) -> str | None: @@ -1063,6 +1075,11 @@ def _field_diff( ): return None + if isinstance(oldval, list): + if (oldset := set(oldval)) != (newset := set(newval)): + return _multi_value_diff(field, oldset, newset) + return None + # Get formatted values for output. oldstr, newstr = old.get(field, ""), new.get(field, "") if field not in new: @@ -1071,8 +1088,7 @@ def _field_diff( if field not in old: return colorize("text_diff_added", f"{field}: {newstr}") - # For strings, highlight changes. For others, colorize the whole - # thing. + # For strings, highlight changes. For others, colorize the whole thing. if isinstance(oldval, str): oldstr, newstr = colordiff(oldstr, newstr) else: diff --git a/docs/changelog.rst b/docs/changelog.rst index eff2e2cc8..58ad3d58f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -28,6 +28,7 @@ Other changes values: |semicolon_space|. For example ``beet modify albumtypes="album; ep"``. Previously, ``\␀`` was used as a separator. This applies to fields such as ``artists``, ``albumtypes`` etc. +- Improve highlighting of multi-valued fields changes. 2.6.2 (February 22, 2026) ------------------------- diff --git a/test/ui/test_field_diff.py b/test/ui/test_field_diff.py index 35f3c6ca7..8480d7d7c 100644 --- a/test/ui/test_field_diff.py +++ b/test/ui/test_field_diff.py @@ -1,3 +1,5 @@ +from textwrap import dedent + import pytest from beets.library import Item @@ -38,6 +40,19 @@ class TestFieldDiff: p({"mb_trackid": None}, {"mb_trackid": "1234"}, "mb_trackid", "mb_trackid: -> [text_diff_added]1234[/]", id="none_to_value"), p({}, {"new_flex": "foo"}, "new_flex", "[text_diff_added]new_flex: foo[/]", id="flex_field_added"), p({"old_flex": "foo"}, {}, "old_flex", "[text_diff_removed]old_flex: foo[/]", id="flex_field_removed"), + p({"albumtypes": ["album", "ep"]}, {"albumtypes": ["ep", "album"]}, "albumtypes", None, id="multi_value_unchanged"), + p( + {"albumtypes": ["ep"]}, + {"albumtypes": ["album", "compilation"]}, + "albumtypes", + dedent(""" + albumtypes: + [text_diff_removed] - ep[/] + [text_diff_added] + album[/] + [text_diff_added] + compilation[/] + """).strip(), + id="multi_value_changed" + ), ], ) # fmt: skip @pytest.mark.parametrize("color", [True], ids=["color_enabled"]) From 4699958f253eb27263f2bc3eef385e9ce7793673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 22 Feb 2026 01:36:02 +0000 Subject: [PATCH 3/4] Remove redundant coloring logic --- beets/ui/__init__.py | 51 ++++++---------------------- beets/ui/commands/import_/display.py | 2 +- test/ui/test_field_diff.py | 2 +- 3 files changed, 13 insertions(+), 42 deletions(-) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index ddd5ee98b..ad14fe1f8 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -571,15 +571,16 @@ def get_color_config() -> dict[ColorName, str]: } -def colorize(color_name: ColorName, text: str) -> str: - """Apply ANSI color formatting to text based on configuration settings. +def _colorize(color_name: ColorName, text: str) -> str: + """Apply ANSI color formatting to text based on configuration settings.""" + color_code = get_color_config()[color_name] + return f"{COLOR_ESCAPE}[{color_code}m{text}{RESET_COLOR}" - Returns colored text when color output is enabled and NO_COLOR environment - variable is not set, otherwise returns plain text unchanged. - """ + +def colorize(color_name: ColorName, text: str) -> str: + """Colorize text when color output is enabled.""" if config["ui"]["color"] and "NO_COLOR" not in os.environ: - color_code = get_color_config()[color_name] - return f"{COLOR_ESCAPE}[{color_code}m{text}{RESET_COLOR}" + return _colorize(color_name, text) return text @@ -643,32 +644,12 @@ def color_len(colored_text): return len(uncolorize(colored_text)) -def _colordiff(a: Any, b: Any) -> tuple[str, str]: - """Given two values, return the same pair of strings except with - their differences highlighted in the specified color. Strings are - highlighted intelligently to show differences; other values are - stringified and highlighted in their entirety. - """ - # First, convert paths to readable format - if isinstance(a, bytes) or isinstance(b, bytes): - # A path field. - a = util.displayable_path(a) - b = util.displayable_path(b) - - if not isinstance(a, str) or not isinstance(b, str): - # Non-strings: use ordinary equality. - if a == b: - return str(a), str(b) - else: - return ( - colorize("text_diff_removed", str(a)), - colorize("text_diff_added", str(b)), - ) - +def colordiff(a: str, b: str) -> tuple[str, str]: + """Intelligently highlight the differences between two strings.""" before = "" after = "" - matcher = SequenceMatcher(lambda x: False, a, b) + matcher = SequenceMatcher(lambda _: False, a, b) for op, a_start, a_end, b_start, b_end in matcher.get_opcodes(): before_part, after_part = a[a_start:a_end], b[b_start:b_end] if op in {"delete", "replace"}: @@ -682,16 +663,6 @@ def _colordiff(a: Any, b: Any) -> tuple[str, str]: return before, after -def colordiff(a, b): - """Colorize differences between two values if color is enabled. - (Like _colordiff but conditional.) - """ - if config["ui"]["color"]: - return _colordiff(a, b) - else: - return str(a), str(b) - - def get_path_formats(subview=None): """Get the configuration's path formats as a list of query/template pairs. diff --git a/beets/ui/commands/import_/display.py b/beets/ui/commands/import_/display.py index bdc44d51f..764dafd39 100644 --- a/beets/ui/commands/import_/display.py +++ b/beets/ui/commands/import_/display.py @@ -127,7 +127,7 @@ class ChangeRepresentation: and artist name. """ # Artist. - artist_l, artist_r = self.cur_artist or "", self.match.info.artist + artist_l, artist_r = self.cur_artist or "", self.match.info.artist or "" if artist_r == VARIOUS_ARTISTS: # Hide artists for VA releases. artist_l, artist_r = "", "" diff --git a/test/ui/test_field_diff.py b/test/ui/test_field_diff.py index 8480d7d7c..d42e55a93 100644 --- a/test/ui/test_field_diff.py +++ b/test/ui/test_field_diff.py @@ -17,7 +17,7 @@ class TestFieldDiff: def patch_colorize(self, monkeypatch): """Patch to return a deterministic string format instead of ANSI codes.""" monkeypatch.setattr( - "beets.ui.colorize", + "beets.ui._colorize", lambda color_name, text: f"[{color_name}]{text}[/]", ) From edfe00516f24d77b625a2e5b1c6557bc710438dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 23 Feb 2026 00:33:24 +0000 Subject: [PATCH 4/4] Handle DelimitedString fields as native lists in edit plugin Treat DelimitedString as a safe YAML-editable type in the edit plugin, allowing multi-valued fields to be edited as native lists. --- beets/dbcore/types.py | 6 ++++-- beetsplug/edit.py | 7 ++++++- docs/changelog.rst | 3 +++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py index 3f94afd05..8907584a4 100644 --- a/beets/dbcore/types.py +++ b/beets/dbcore/types.py @@ -66,6 +66,8 @@ class Type(ABC, Generic[T, N]): """The `Query` subclass to be used when querying the field. """ + # For sequence-like types, keep ``model_type`` unsubscripted as it's used + # for ``isinstance`` checks. Use ``list`` instead of ``list[str]`` model_type: type[T] """The Python type that is used to represent the value in the model. @@ -287,7 +289,7 @@ class String(BaseString[str, Any]): model_type = str -class DelimitedString(BaseString[list[str], list[str]]): +class DelimitedString(BaseString[list, list]): # type: ignore[type-arg] r"""A list of Unicode strings, represented in-database by a single string containing delimiter-separated values. @@ -297,7 +299,7 @@ class DelimitedString(BaseString[list[str], list[str]]): as it contains a backslash character. """ - model_type = list[str] + model_type = list fmt_delimiter = "; " def __init__(self, db_delimiter: str): diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 46e756122..0f358c9a1 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -30,7 +30,12 @@ from beets.util import PromptChoice # 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) +SAFE_TYPES = ( + types.BaseFloat, + types.BaseInteger, + types.Boolean, + types.DelimitedString, +) class ParseError(Exception): diff --git a/docs/changelog.rst b/docs/changelog.rst index 58ad3d58f..9f73a5725 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -29,6 +29,9 @@ Other changes Previously, ``\␀`` was used as a separator. This applies to fields such as ``artists``, ``albumtypes`` etc. - Improve highlighting of multi-valued fields changes. +- :doc:`plugins/edit`: Editing multi-valued fields now behaves more naturally, + with list values handled directly to make metadata edits smoother and more + predictable. 2.6.2 (February 22, 2026) -------------------------