From f5adad7e4394a479878a3d61b6d0319059c56f80 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 19 Apr 2026 18:14:46 +0200 Subject: [PATCH 1/8] smartplaylist: Tests for CLI overhaul --- test/plugins/test_smartplaylist.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index f2ecd3dda0..46cede23e4 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -591,3 +591,31 @@ def test_splupdate_unknown_playlist_error_is_sorted_and_quoted(self): "No playlist matching any of " "'a one.m3u' 'rock'\"'\"'n roll.m3u' 'z last.m3u' found" ) + + def test_splupdate_log_output(self): + with self.assertLogs("beets.smartplaylist", level="INFO") as logs: + self.run_with_output("splupdate", "my_playlist") + + output = "\n".join(logs.output) + assert "Updating 1 smart playlists..." in output + assert "Creating playlist my_playlist.m3u: 1 tracks." in output + assert "1 playlists updated" in output + + def test_splupdate_verbose_log_output(self): + with self.assertLogs("beets.smartplaylist", level="DEBUG") as logs: + self.run_with_output("splupdate", "my_playlist") + + output = "\n".join(logs.output) + assert "Updating 1 smart playlists..." in output + assert "Creating playlist my_playlist.m3u: 1 tracks." in output + assert "the ärtist - " in output + assert "1 playlists updated" in output + + def test_splupdate_pretend_log_output(self): + with self.assertLogs("beets.smartplaylist", level="INFO") as logs: + self.run_with_output("splupdate", "--pretend", "my_playlist") + + output = "\n".join(logs.output) + assert "Updating 1 smart playlists..." in output + assert "Creating playlist my_playlist.m3u: 1 tracks." in output + assert "1 playlists would be updated" in output From 4fb9c9a816eee62f4ec5b29eae4042bf5eed4e0f Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Fri, 3 Apr 2026 20:00:12 +0200 Subject: [PATCH 2/8] smartplaylist: Document CLI output overhaul changes --- docs/plugins/smartplaylist.rst | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/docs/plugins/smartplaylist.rst b/docs/plugins/smartplaylist.rst index 6982f1e620..80582c35cf 100644 --- a/docs/plugins/smartplaylist.rst +++ b/docs/plugins/smartplaylist.rst @@ -95,18 +95,17 @@ You can also use this plugin together with the :doc:`mpdupdate`, in order to automatically notify MPD of the playlist change, by adding ``mpdupdate`` to the ``plugins`` line in your config file *after* the ``smartplaylist`` plugin. -While changing existing playlists in the beets configuration it can help to use -the ``--pretend`` option to find out if the edits work as expected. The results -of the queries will be printed out instead of being written to the playlist -file. +While working on smart playlist queries in the beets configuration it can help +to use the ``--pretend`` option to find out if the edits work as expected before +writing changes. To list the tracks matching the query, switch to the DEBUG log +level. :: - $ beet splupdate --pretend BeatlesUniverse.m3u + $ beet -v splupdate --pretend BeatlesUniverse.m3u -The ``pretend_paths`` configuration option sets whether the items should be -displayed as per the user's ``format_item`` setting or what the file paths as -they would be written to the m3u file look like. +The ``format`` configuration option defaults to ``$artist - $title``, and can be +set up to a custom format string. In case you want to export additional fields from the beets database into the generated playlists, you can do so by specifying them within the ``fields`` @@ -168,8 +167,6 @@ options are: example, you could use the URL for a server where the music is stored. Default: empty string. - **urlencode**: URL-encode all paths. Default: ``no``. -- **pretend_paths**: When running with ``--pretend``, show the actual file paths - that will be written to the m3u file. Default: ``false``. - **uri_format**: Template with an ``$id`` placeholder used generate a playlist item URI, e.g. ``http://beets:8337/item/$id/file``. When this option is specified, the local path-related options ``dest_regen``, ``prefix``, @@ -179,9 +176,11 @@ options are: playlist. This allows using e.g. the ``id`` field within other tools such as the webm3u_ and Beetstream_ plugins. To use this option, you must set the ``output`` option to ``extm3u``. +- **format**: Specify the format a playlist item is logged in ``DEBUG`` level. + Default: ``$artist - $title``. For many configuration options, there is a corresponding CLI option, e.g. ``--playlist-dir``, ``--dest-regen``, ``--relative-to``, ``--prefix``, ``--forward-slash``, ``--urlencode``, ``--uri-format``, ``--output``, -``--pretend-paths``. CLI options take precedence over those specified within the +``--format``. CLI options take precedence over those specified within the configuration file. From a1391cf6cd9a70e7a64ba92623df50c35ae9d1a4 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 5 Apr 2026 09:37:44 +0200 Subject: [PATCH 3/8] smartplaylist: Use .get(bool) for all boolean configs which enhances code readability for future readers. --- beetsplug/smartplaylist.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 10fba65322..2a4ba7a0cf 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -278,7 +278,7 @@ def update_playlists(self, lib: Library, pretend: bool = False) -> None: ) tpl = self.config["uri_format"].get() prefix = bytestring_path(self.config["prefix"].as_str()) - dest_regen = self.config["dest_regen"].get() + dest_regen = self.config["dest_regen"].get(bool) relative_to = self.config["relative_to"].get() if relative_to: relative_to = normpath(relative_to) @@ -334,9 +334,9 @@ def update_playlists(self, lib: Library, pretend: bool = False) -> None: item_uri = item.destination() if relative_to: item_uri = os.path.relpath(item_uri, relative_to) - if self.config["forward_slash"].get(): + if self.config["forward_slash"].get(bool): item_uri = path_as_posix(item_uri) - if self.config["urlencode"]: + if self.config["urlencode"].get(bool): item_uri = bytestring_path( pathname2url(os.fsdecode(item_uri)) ) From 6b3241f4c9ffcc9505c464666bfc6f9f9e25ea4c Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 5 Apr 2026 09:48:06 +0200 Subject: [PATCH 4/8] smartplaylist: Fix duplicate entries and count bug Deduplicate playlist output by URI instead of comparing bytes to PlaylistItem objects. Increment matched/pretend counts only when a new entry is actually added. This prevents duplicate lines when the same track is reached via multiple query paths. --- beetsplug/smartplaylist.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 2a4ba7a0cf..d5f72491b6 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -283,8 +283,10 @@ def update_playlists(self, lib: Library, pretend: bool = False) -> None: if relative_to: relative_to = normpath(relative_to) - # Maps playlist filenames to lists of track filenames. + # Maps playlist filenames to lists of track entries and URI sets used + # to deduplicate output lines. m3us: dict[str, list[PlaylistItem]] = {} + m3u_uris_by_name: dict[str, set[Any]] = {} for playlist in self._matched_playlists: name, (query, q_sort), (album_query, a_q_sort) = playlist @@ -326,6 +328,7 @@ def update_playlists(self, lib: Library, pretend: bool = False) -> None: m3u_name = sanitize_path(m3u_name, lib.replacements) if m3u_name not in m3us: m3us[m3u_name] = [] + m3u_uris_by_name[m3u_name] = set() item_uri = item.path if tpl: item_uri = tpl.replace("$id", str(item.id)).encode("utf-8") @@ -342,7 +345,8 @@ def update_playlists(self, lib: Library, pretend: bool = False) -> None: ) item_uri = prefix + item_uri - if item_uri not in m3us[m3u_name]: + if item_uri not in m3u_uris_by_name[m3u_name]: + m3u_uris_by_name[m3u_name].add(item_uri) m3us[m3u_name].append(PlaylistItem(item, item_uri)) if pretend and self.config["pretend_paths"]: print(displayable_path(item_uri)) From c02de5ff630ac052aee022532f0de1f67517f555 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Mon, 29 Sep 2025 12:13:18 +0200 Subject: [PATCH 5/8] smartplaylist: CLI output overhaul - Enrich CLI per playlist summaries with matched track counts - Move per-track log lines to DEBUG log level (beet -vv) - Make per-track log lines configurable (format) - Remove the pretend_paths config/flag (now configurable anyway) - Improve existing help texts --- beetsplug/smartplaylist.py | 45 ++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index d5f72491b6..dc7aafdde5 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -29,7 +29,6 @@ from beets.plugins import send as send_event from beets.util import ( bytestring_path, - displayable_path, mkdirall, normpath, path_as_posix, @@ -64,7 +63,7 @@ def __init__(self) -> None: "forward_slash": False, "prefix": "", "urlencode": False, - "pretend_paths": False, + "format": "$artist - $title", "output": "m3u", } ) @@ -88,12 +87,7 @@ def commands(self) -> list[ui.Subcommand]: action="store_true", help="display query results but don't write playlist files.", ) - spl_update.parser.add_option( - "--pretend-paths", - action="store_true", - dest="pretend_paths", - help="in pretend mode, log the playlist item URIs/paths.", - ) + spl_update.parser.add_format_option(target="item") spl_update.parser.add_option( "-d", "--playlist-dir", @@ -263,15 +257,10 @@ def db_change(self, lib: Library, model: Item | Album) -> None: self._unmatched_playlists -= self._matched_playlists def update_playlists(self, lib: Library, pretend: bool = False) -> None: - if pretend: - self._log.info( - "Showing query results for {} smart playlists...", - len(self._matched_playlists), - ) - else: - self._log.info( - "Updating {} smart playlists...", len(self._matched_playlists) - ) + self._log.info( + "Updating {} smart playlists...", + len(self._matched_playlists), + ) playlist_dir = bytestring_path( self.config["playlist_dir"].as_filename() @@ -289,11 +278,8 @@ def update_playlists(self, lib: Library, pretend: bool = False) -> None: m3u_uris_by_name: dict[str, set[Any]] = {} for playlist in self._matched_playlists: + matched_count = 0 name, (query, q_sort), (album_query, a_q_sort) = playlist - if pretend: - self._log.info("Results for playlist {}:", name) - else: - self._log.info("Creating playlist {}", name) items = [] # Handle tuple/list of queries (preserves order) @@ -323,6 +309,7 @@ def update_playlists(self, lib: Library, pretend: bool = False) -> None: # As we allow tags in the m3u names, we'll need to iterate through # the items and generate the correct m3u file names. + matched_items: list[tuple[Any, Any]] = [] for item in items: m3u_name = item.evaluate_template(name, True) m3u_name = sanitize_path(m3u_name, lib.replacements) @@ -348,10 +335,16 @@ def update_playlists(self, lib: Library, pretend: bool = False) -> None: if item_uri not in m3u_uris_by_name[m3u_name]: m3u_uris_by_name[m3u_name].add(item_uri) m3us[m3u_name].append(PlaylistItem(item, item_uri)) - if pretend and self.config["pretend_paths"]: - print(displayable_path(item_uri)) - elif pretend: - print(item) + matched_items.append((item, item_uri)) + matched_count += 1 + + self._log.info( + "Creating playlist {}: {} tracks.", name, matched_count + ) + for item, item_uri in matched_items: + self._log.debug( + item.evaluate_template(self.config["format"].as_str()) + ) if not pretend: # Write all of the accumulated track lists to files. @@ -391,7 +384,7 @@ def update_playlists(self, lib: Library, pretend: bool = False) -> None: if pretend: self._log.info( - "Displayed results for {} playlists", + "{} playlists would be updated", len(self._matched_playlists), ) else: From db32ee4002325f9373a9f1d2b970604e11ac0341 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 5 Apr 2026 08:55:34 +0200 Subject: [PATCH 6/8] smartplaylist: Rework opts defaults handling - Provide defaults when adding CLI options already - Use a single config.set() call instead of using a helper method - Since we cant control much of the add_format_option helper, let's define that option here manually instead (otherwise we would have to handle a default of None) --- beetsplug/smartplaylist.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index dc7aafdde5..7d29422294 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -87,19 +87,27 @@ def commands(self) -> list[ui.Subcommand]: action="store_true", help="display query results but don't write playlist files.", ) - spl_update.parser.add_format_option(target="item") + spl_update.parser.add_option( + "-f", + "--format", + type="string", + default=self.config["format"].get(), + help="print per-track log lines with custom format", + ) spl_update.parser.add_option( "-d", "--playlist-dir", dest="playlist_dir", metavar="PATH", type="string", + default=self.config["playlist_dir"].get(), help="directory to write the generated playlist files to.", ) spl_update.parser.add_option( "--dest-regen", action="store_true", dest="dest_regen", + default=self.config["dest_regen"].get(bool), help="regenerate the destination path as 'move' or 'convert' " "commands would do.", ) @@ -108,33 +116,39 @@ def commands(self) -> list[ui.Subcommand]: dest="relative_to", metavar="PATH", type="string", + default=self.config["relative_to"].get(), help="generate playlist item paths relative to this path.", ) spl_update.parser.add_option( "--prefix", type="string", + default=self.config["prefix"].get(), help="prepend string to every path in the playlist file.", ) spl_update.parser.add_option( "--forward-slash", action="store_true", dest="forward_slash", + default=self.config["forward_slash"].get(bool), help="force forward slash in paths within playlists.", ) spl_update.parser.add_option( "--urlencode", action="store_true", + default=self.config["urlencode"].get(bool), help="URL-encode all paths.", ) spl_update.parser.add_option( "--uri-format", dest="uri_format", type="string", + default=self.config["uri_format"].get(), help="playlist item URI template, e.g. http://beets:8337/item/$id/file.", ) spl_update.parser.add_option( "--output", type="string", + default=self.config["output"].get(), help="specify the playlist format: m3u|extm3u.", ) spl_update.func = self.update_cmd @@ -166,14 +180,9 @@ def update_cmd(self, lib: Library, opts: Any, args: list[str]) -> None: else: self._matched_playlists = self._unmatched_playlists - self.__apply_opts_to_config(opts) + self.config.set(vars(opts)) self.update_playlists(lib, opts.pretend) - def __apply_opts_to_config(self, opts: Any) -> None: - for k, v in opts.__dict__.items(): - if v is not None and k in self.config: - self.config[k] = v - def _parse_one_query( self, playlist: dict[str, Any], key: str, model_cls: type ) -> tuple[PlaylistQuery, Sort | None]: From 04904902f5176bfca971da5f1099e23e781df515 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 19 Apr 2026 18:26:53 +0200 Subject: [PATCH 7/8] smartplaylist: Fix existing tests after CLI overhaul --- test/plugins/test_smartplaylist.py | 41 ++++++++++++++---------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index 46cede23e4..a02f5cfd32 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -14,7 +14,7 @@ # TODO: Tests in this fire are very bad. Stop using Mocks in this module. -from os import path, remove +import os from pathlib import Path from shutil import rmtree from tempfile import mkdtemp @@ -171,9 +171,9 @@ def test_playlist_update(self): spl = SmartPlaylistPlugin() i = Mock(path=b"/tagada.mp3") - i.evaluate_template.side_effect = lambda pl, _: pl.replace( - b"$title", b"ta:ga:da" - ).decode() + i.evaluate_template.side_effect = lambda pl, *_: os.fsdecode( + pl + ).replace("$title", "ta:ga:da") lib = Mock() lib.replacements = CHAR_REPLACE @@ -212,10 +212,9 @@ def test_playlist_update_output_extm3u(self): type(i).title = PropertyMock(return_value="fake title") type(i).length = PropertyMock(return_value=300.123) type(i).path = PropertyMock(return_value=b"/tagada.mp3") - i.evaluate_template.side_effect = lambda pl, _: pl.replace( - b"$title", - b"ta:ga:da", - ).decode() + i.evaluate_template.side_effect = lambda pl, *_: os.fsdecode( + pl + ).replace("$title", "ta:ga:da") lib = Mock() lib.replacements = CHAR_REPLACE @@ -262,10 +261,9 @@ def test_playlist_update_output_extm3u_fields(self): type(i).path = PropertyMock(return_value=b"/tagada.mp3") a = {"id": 456, "genres": ["Rock", "Pop"]} i.__getitem__.side_effect = a.__getitem__ - i.evaluate_template.side_effect = lambda pl, _: pl.replace( - b"$title", - b"ta:ga:da", - ).decode() + i.evaluate_template.side_effect = lambda pl, *_: os.fsdecode( + pl + ).replace("$title", "ta:ga:da") lib = Mock() lib.replacements = CHAR_REPLACE @@ -308,9 +306,9 @@ def test_playlist_update_uri_format(self): i = MagicMock() type(i).id = PropertyMock(return_value=3) type(i).path = PropertyMock(return_value=b"/tagada.mp3") - i.evaluate_template.side_effect = lambda pl, _: pl.replace( - b"$title", b"ta:ga:da" - ).decode() + i.evaluate_template.side_effect = lambda pl, *_: os.fsdecode( + pl + ).replace("$title", "ta:ga:da") lib = Mock() lib.replacements = CHAR_REPLACE @@ -471,10 +469,9 @@ def test_playlist_update_dest_regen(self): ) # Set a path which would be equal to the one returned by `item.destination`. type(i).destination = PropertyMock(return_value=lambda: b"/tagada.mp3") - i.evaluate_template.side_effect = lambda pl, _: pl.replace( - b"$title", - b"ta:ga:da", - ).decode() + i.evaluate_template.side_effect = lambda pl, *_: os.fsdecode( + pl + ).replace("$title", "ta:ga:da") lib = Mock() lib.replacements = CHAR_REPLACE @@ -564,15 +561,15 @@ def test_splupdate(self): m3u_path = self.temp_dir_path / "my_playlist.m3u" assert m3u_path.exists() assert m3u_path.read_bytes() == self.item.path + b"\n" - remove(syspath(m3u_path)) + os.remove(syspath(m3u_path)) self.run_with_output("splupdate", "my_playlist.m3u") assert m3u_path.read_bytes() == self.item.path + b"\n" - remove(syspath(m3u_path)) + os.remove(syspath(m3u_path)) self.run_with_output("splupdate") for name in (b"my_playlist.m3u", b"all.m3u"): - with open(path.join(self.temp_dir, name), "rb") as f: + with open(os.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): From e801e9028aa1d49c5094be638902259327df27ca Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 19 Apr 2026 18:10:55 +0200 Subject: [PATCH 8/8] Changelog for #6183 CLI output overhaul --- docs/changelog.rst | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index a240ccc510..8273c0ab4e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,9 +9,16 @@ below! Unreleased ---------- -.. - New features - ~~~~~~~~~~~~ +New features +~~~~~~~~~~~~ + +- :doc:`plugins/smartplaylist`: The ``splupdate`` command output is + restructured. The per-playlist summary now includes a track count. Per-track + details are shown only when ``-v`` flag is provided (``beet -v splupdate``). + The ``--pretend`` flag produces the same output but reports *"N playlists + would be updated"* instead of *"N playlists updated"*. The ``--format`` option + allows customizing the track line format. The ``--pretend-paths`` option was + removed (use ``--format='$path'`` instead). :bug:`6183` .. Bug fixes