From 9c315c865aefc6a06eb5177e5d673a1c74d748f7 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Fri, 3 Apr 2026 12:07:08 +0200 Subject: [PATCH 1/4] smartplaylist: Tiny refactor use items for dict --- beetsplug/smartplaylist.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 0a050794c4..eea17ba9e8 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -348,7 +348,7 @@ def update_playlists(self, lib: Library, pretend: bool = False) -> None: if not pretend: # Write all of the accumulated track lists to files. - for m3u in m3us: + for m3u, entries in m3us.items(): m3u_path = normpath( os.path.join(playlist_dir, bytestring_path(m3u)) ) @@ -364,7 +364,7 @@ def update_playlists(self, lib: Library, pretend: bool = False) -> None: if extm3u: keys = self.config["fields"].get(list) f.write(b"#EXTM3U\n") - for entry in m3us[m3u]: + for entry in entries: item = entry.item comment = "" if extm3u: From f8766c1fb8623fe9c9c541b5e4fea5f703d12c16 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Mon, 29 Sep 2025 11:46:48 +0200 Subject: [PATCH 2/4] smartplaylist: Display available lists cp-ready When supplying an invalid playlist name, the list of all playlists is shell quoted (single quotes) and becomes usable for copy/paste-ing to the user's shell. --- beetsplug/smartplaylist.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index eea17ba9e8..10fba65322 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -17,6 +17,7 @@ from __future__ import annotations import os +from shlex import quote as shell_quote from typing import TYPE_CHECKING, Any, TypeAlias from urllib.parse import quote from urllib.request import pathname2url @@ -160,8 +161,10 @@ def update_cmd(self, lib: Library, opts: Any, args: list[str]) -> None: } if not playlists: unmatched = [name for name, _, _ in self._unmatched_playlists] + unmatched.sort() + quoted_names = " ".join(shell_quote(name) for name in unmatched) raise ui.UserError( - f"No playlist matching any of {unmatched} found" + f"No playlist matching any of {quoted_names} found" ) self._matched_playlists = playlists From 6657649eaadeab3ef01c910994c905c8a83075e1 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Mon, 2 Mar 2026 08:12:23 +0100 Subject: [PATCH 3/4] Changelog for #6404 smartplaylist copy-paste fix Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 80cbf0e9d3..dd999ab689 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,6 +22,10 @@ New features deprecate ``overwrite``. - :doc:`plugins/autobpm`: The "BPM already exists for item" log message can now be hidden with the ``--quiet`` flag. +- :doc:`plugins/smartplaylist`: The list of available playlists shown when an + unknown playlist name is passed as an argument is now sorted alphabetically + and printed space-delimited and POSIX shell-quoted when required. This makes + it easier to copy and paste multiple playlists for further use in the shell. Bug fixes ~~~~~~~~~ From b32f220523e99a42c686ab88ee41ec0866d7d89c Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Fri, 3 Apr 2026 17:42:36 +0200 Subject: [PATCH 4/4] smartplaylist: Test new shell quoting behavior --- test/plugins/test_smartplaylist.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index c62021184c..f2ecd3dda0 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -574,3 +574,20 @@ def test_splupdate(self): for name in (b"my_playlist.m3u", b"all.m3u"): with open(path.join(self.temp_dir, name), "rb") as f: assert f.read() == self.item.path + b"\n" + + def test_splupdate_unknown_playlist_error_is_sorted_and_quoted(self): + config["smartplaylist"]["playlists"].set( + [ + {"name": "z last.m3u", "query": self.item.title}, + {"name": "rock'n roll.m3u", "query": self.item.title}, + {"name": "a one.m3u", "query": self.item.title}, + ] + ) + + with pytest.raises(UserError) as exc_info: + self.run_with_output("splupdate", "tagada") + + assert str(exc_info.value) == ( + "No playlist matching any of " + "'a one.m3u' 'rock'\"'\"'n roll.m3u' 'z last.m3u' found" + )