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