Fix multi-value delimiter handling in templates

- 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.
This commit is contained in:
Šarūnas Nejus 2026-02-22 00:21:09 +00:00
parent 13e978ca0e
commit 9d237d10fc
No known key found for this signature in database
8 changed files with 69 additions and 27 deletions

View file

@ -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("\\")

View file

@ -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)

View file

@ -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)
-------------------------

View file

@ -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 -------------------------------------------------

View file

@ -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

View file

@ -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``

View file

@ -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, / , & }")

View file

@ -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):