diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index ff5e25612d..0a050794c4 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -53,6 +53,7 @@ def __init__(self) -> None: super().__init__() self.config.add( { + "dest_regen": False, "relative_to": None, "playlist_dir": ".", "auto": True, @@ -100,6 +101,13 @@ def commands(self) -> list[ui.Subcommand]: type="string", help="directory to write the generated playlist files to.", ) + spl_update.parser.add_option( + "--dest-regen", + action="store_true", + dest="dest_regen", + help="regenerate the destination path as 'move' or 'convert' " + "commands would do.", + ) spl_update.parser.add_option( "--relative-to", dest="relative_to", @@ -267,6 +275,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() relative_to = self.config["relative_to"].get() if relative_to: relative_to = normpath(relative_to) @@ -318,6 +327,8 @@ def update_playlists(self, lib: Library, pretend: bool = False) -> None: if tpl: item_uri = tpl.replace("$id", str(item.id)).encode("utf-8") else: + if dest_regen is True: + item_uri = item.destination() if relative_to: item_uri = os.path.relpath(item_uri, relative_to) if self.config["forward_slash"].get(): diff --git a/docs/changelog.rst b/docs/changelog.rst index 6944f1e7b5..7fd78aef5e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,6 +15,11 @@ New features - :doc:`plugins/discogs`: Add :conf:`plugins.discogs:extra_tags` option to use additional tags (such as ``barcode``, ``catalognum``, ``country``, ``label``, ``media``, and ``year``) in Discogs search queries. +- :doc:`plugins/smartplaylist`: Add new configuration option ``dest_regen`` to + regenerate items' path in the generated playlist instead of using those in the + library. This is useful when items have been imported in don't copy-move (``-C + -M``) mode in the library but are later passed through the ``convert`` plugin + which will regenerate new paths according to the Beets path format. Bug fixes ~~~~~~~~~ diff --git a/docs/plugins/smartplaylist.rst b/docs/plugins/smartplaylist.rst index 48060ea79c..6982f1e620 100644 --- a/docs/plugins/smartplaylist.rst +++ b/docs/plugins/smartplaylist.rst @@ -153,6 +153,11 @@ options are: ``yes``. - **playlist_dir**: Where to put the generated playlist files. Default: The current working directory (i.e., ``'.'``). +- **dest_regen**: Regenerate the destination path as ``move`` or ``convert`` + commands would do. This operation will happen before ``relative_to`` and + ``prefix``. Helpful to generate playlists compatible with the ``convert`` + plugin when items have been imported with the ``-C -M`` options. Default: + ``false``. - **relative_to**: Generate paths in the playlist files relative to a base directory. If you intend to use this plugin to generate playlists for MPD, point this to your MPD music directory. Default: Use absolute paths. @@ -167,8 +172,8 @@ options are: 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 ``prefix``, ``relative_to``, - ``forward_slash`` and ``urlencode`` are ignored. + specified, the local path-related options ``dest_regen``, ``prefix``, + ``relative_to``, ``forward_slash`` and ``urlencode`` are ignored. - **output**: Specify the playlist format: m3u|extm3u. Default ``m3u``. - **fields**: Specify the names of the additional item fields to export into the playlist. This allows using e.g. the ``id`` field within other tools such as @@ -176,6 +181,7 @@ options are: ``output`` option to ``extm3u``. For many configuration options, there is a corresponding CLI option, e.g. -``--playlist-dir``, ``--relative-to``, ``--prefix``, ``--forward-slash``, -``--urlencode``, ``--uri-format``, ``--output``, ``--pretend-paths``. CLI -options take precedence over those specified within the configuration file. +``--playlist-dir``, ``--dest-regen``, ``--relative-to``, ``--prefix``, +``--forward-slash``, ``--urlencode``, ``--uri-format``, ``--output``, +``--pretend-paths``. CLI options take precedence over those specified within the +configuration file. diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index d1125158f0..c62021184c 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -12,6 +12,7 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. +# TODO: Tests in this fire are very bad. Stop using Mocks in this module. from os import path, remove from pathlib import Path @@ -457,6 +458,88 @@ def items_side_effect(query, sort): # Verify i2 is not duplicated assert content.count(b"/item2.mp3") == 1 + def test_playlist_update_dest_regen(self): + spl = SmartPlaylistPlugin() + + i = MagicMock() + type(i).artist = PropertyMock(return_value="fake artist") + type(i).title = PropertyMock(return_value="fake title") + type(i).length = PropertyMock(return_value=300.123) + # Set a path which is not equal to the one returned by `item.destination`. + type(i).path = PropertyMock( + return_value=b"/imported/path/with/dont/move/tagada.mp3" + ) + # 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() + + lib = Mock() + lib.replacements = CHAR_REPLACE + lib.items.return_value = [i] + lib.albums.return_value = [] + + q = Mock() + a_q = Mock() + pl = b"$title-my.m3u", (q, None), (a_q, None) + spl._matched_playlists = {pl} + + dir = mkdtemp() + config["smartplaylist"]["output"] = "extm3u" + config["smartplaylist"]["prefix"] = "http://beets:8337/files" + config["smartplaylist"]["relative_to"] = False + config["smartplaylist"]["playlist_dir"] = str(dir) + + # Test when `dest_regen` is set to True: + # Intended behavior is to use the path of `i.destination`. + + config["smartplaylist"]["dest_regen"] = True + try: + spl.update_playlists(lib) + except Exception: + rmtree(syspath(dir)) + raise + + lib.items.assert_called_once_with(q, None) + lib.albums.assert_called_once_with(a_q, None) + + m3u_filepath = Path(dir, "ta_ga_da-my_playlist_.m3u") + assert m3u_filepath.exists() + with open(syspath(m3u_filepath), "rb") as f: + content = f.read() + rmtree(syspath(dir)) + + assert content == ( + b"#EXTM3U\n" + b"#EXTINF:300,fake artist - fake title\n" + b"http://beets:8337/files/tagada.mp3\n" + ) + + # Test when `dest_regen` is set to False: + # Intended behavior is to use the path of `i.path`. + + config["smartplaylist"]["dest_regen"] = False + + try: + spl.update_playlists(lib) + except Exception: + rmtree(syspath(dir)) + raise + + m3u_filepath = Path(dir, "ta_ga_da-my_playlist_.m3u") + assert m3u_filepath.exists() + with open(syspath(m3u_filepath), "rb") as f: + content = f.read() + rmtree(syspath(dir)) + + assert content == ( + b"#EXTM3U\n" + b"#EXTINF:300,fake artist - fake title\n" + b"http://beets:8337/files/imported/path/with/dont/move/tagada.mp3\n" + ) + class SmartPlaylistCLITest(IOMixin, PluginTestCase): plugin = "smartplaylist"