diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 6e20cc21b..c892a6040 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -49,6 +49,7 @@ class SmartPlaylistPlugin(BeetsPlugin): "prefix": "", "urlencode": False, "pretend_paths": False, + "extm3u": False, } ) @@ -71,6 +72,17 @@ class SmartPlaylistPlugin(BeetsPlugin): action="store_true", help="display query results but don't write playlist files.", ) + spl_update.parser.add_option( + "--extm3u", + action="store_true", + help="add artist/title as m3u8 comments to playlists.", + ) + spl_update.parser.add_option( + "--no-extm3u", + action="store_false", + dest="extm3u", + help="do not add artist/title as extm3u comments to playlists.", + ) spl_update.func = self.update_cmd return [spl_update] @@ -99,7 +111,7 @@ class SmartPlaylistPlugin(BeetsPlugin): else: self._matched_playlists = self._unmatched_playlists - self.update_playlists(lib, opts.pretend) + self.update_playlists(lib, opts.extm3u, opts.pretend) def build_queries(self): """ @@ -185,7 +197,7 @@ class SmartPlaylistPlugin(BeetsPlugin): self._unmatched_playlists -= self._matched_playlists - def update_playlists(self, lib, pretend=False): + def update_playlists(self, lib, extm3u=None, pretend=False): if pretend: self._log.info( "Showing query results for {0} smart playlists...", @@ -230,7 +242,7 @@ class SmartPlaylistPlugin(BeetsPlugin): if relative_to: item_path = os.path.relpath(item.path, relative_to) if item_path not in m3us[m3u_name]: - m3us[m3u_name].append(item_path) + m3us[m3u_name].append({"item": item, "path": item_path}) if pretend and self.config["pretend_paths"]: print(displayable_path(item_path)) elif pretend: @@ -244,13 +256,23 @@ class SmartPlaylistPlugin(BeetsPlugin): os.path.join(playlist_dir, bytestring_path(m3u)) ) mkdirall(m3u_path) + extm3u = extm3u is None and self.config["extm3u"] or extm3u with open(syspath(m3u_path), "wb") as f: - for path in m3us[m3u]: + if extm3u: + f.write(b"#EXTM3U\n") + for entry in m3us[m3u]: + path = entry["path"] + item = entry["item"] if self.config["forward_slash"].get(): path = path_as_posix(path) if self.config["urlencode"]: path = bytestring_path(pathname2url(path)) - f.write(prefix + path + b"\n") + comment = "" + if extm3u: + comment = "#EXTINF:{},{} - {}\n".format( + int(item.length), item.artist, item.title + ) + f.write(comment.encode("utf-8") + prefix + path + b"\n") # Send an event when playlists were updated. send_event("smartplaylist_update") diff --git a/docs/changelog.rst b/docs/changelog.rst index 5b074f592..9259b5933 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -148,6 +148,7 @@ New features: `synced` option to prefer synced lyrics over plain lyrics. * :ref:`import-cmd`: Expose import.quiet_fallback as CLI option. * :ref:`import-cmd`: Expose `import.incremental_skip_later` as CLI option. +* :doc:`/plugins/smartplaylist`: Add new config option `smartplaylist.extm3u`. Bug fixes: diff --git a/docs/plugins/smartplaylist.rst b/docs/plugins/smartplaylist.rst index e687a68a4..6a78124e1 100644 --- a/docs/plugins/smartplaylist.rst +++ b/docs/plugins/smartplaylist.rst @@ -118,3 +118,4 @@ other configuration options are: - **urlencoded**: 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``. +- **extm3u**: Generate extm3u/m3u8 playlists. Default ``ǹo``. diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index a3a03b54c..f36601267 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -19,7 +19,7 @@ from shutil import rmtree from tempfile import mkdtemp from test import _common from test.helper import TestHelper -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock, Mock, PropertyMock from beets import config from beets.dbcore import OrQuery @@ -191,6 +191,56 @@ class SmartPlaylistTest(_common.TestCase): self.assertEqual(content, b"/tagada.mp3\n") + def test_playlist_update_extm3u(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) + 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() + + 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 = bytestring_path(mkdtemp()) + config["smartplaylist"]["extm3u"] = True + config["smartplaylist"]["prefix"] = "http://beets:8337/files" + config["smartplaylist"]["relative_to"] = False + config["smartplaylist"]["playlist_dir"] = py3_path(dir) + 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.join(dir, b"ta_ga_da-my_playlist_.m3u") + self.assertExists(m3u_filepath) + with open(syspath(m3u_filepath), "rb") as f: + content = f.read() + rmtree(syspath(dir)) + + self.assertEqual( + content, + b"#EXTM3U\n" + + b"#EXTINF:300,fake artist - fake title\n" + + b"http://beets:8337/files/tagada.mp3\n", + ) + class SmartPlaylistCLITest(_common.TestCase, TestHelper): def setUp(self):