From 1b1db0a6e9b2435b16873954e04891ab89b40f5a Mon Sep 17 00:00:00 2001 From: Max Goltzsche Date: Fri, 23 Feb 2024 03:52:31 +0100 Subject: [PATCH] smartplaylist: allow exporting item fields Allow generating m3u8 playlists so that they contain additional item fields such as the `id`. The feature is required by the mgoltzsche/beets-webm3u plugin (M3U server) to transform playlists using a request based item URI template which may require additional fields such as the `id`, e.g. `beets:library:track;$id`. --- beetsplug/smartplaylist.py | 25 +++++++++++--- docs/changelog.rst | 1 + docs/plugins/index.rst | 4 +++ docs/plugins/smartplaylist.rst | 6 ++++ test/plugins/test_smartplaylist.py | 52 ++++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 5 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 12a1c92181..7e16d3390e 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -16,6 +16,7 @@ """ +import json import os from urllib.request import pathname2url @@ -46,6 +47,7 @@ def __init__(self): "auto": True, "playlists": [], "uri_format": None, + "fields": [], "forward_slash": False, "prefix": "", "urlencode": False, @@ -297,7 +299,7 @@ def update_playlists(self, lib, pretend=False): item_uri = prefix + item_uri if item_uri not in m3us[m3u_name]: - m3us[m3u_name].append({"item": item, "uri": item_uri}) + m3us[m3u_name].append(PlaylistItem(item, item_uri)) if pretend and self.config["pretend_paths"]: print(displayable_path(item_uri)) elif pretend: @@ -317,16 +319,23 @@ def update_playlists(self, lib, pretend=False): raise Exception(msg.format(pl_format)) m3u8 = pl_format == "m3u8" with open(syspath(m3u_path), "wb") as f: + keys = [] if m3u8: + keys = self.config["fields"].get(list) f.write(b"#EXTM3U\n") for entry in m3us[m3u]: - item = entry["item"] + item = entry.item comment = "" if m3u8: - comment = "#EXTINF:{},{} - {}\n".format( - int(item.length), item.artist, item.title + attr = [(k, entry.item[k]) for k in keys] + al = [ + f" {a[0]}={json.dumps(str(a[1]))}" for a in attr + ] + attrs = "".join(al) + comment = "#EXTINF:{}{},{} - {}\n".format( + int(item.length), attrs, item.artist, item.title ) - f.write(comment.encode("utf-8") + entry["uri"] + b"\n") + f.write(comment.encode("utf-8") + entry.uri + b"\n") # Send an event when playlists were updated. send_event("smartplaylist_update") @@ -339,3 +348,9 @@ def update_playlists(self, lib, pretend=False): self._log.info( "{0} playlists updated", len(self._matched_playlists) ) + + +class PlaylistItem: + def __init__(self, item, uri): + self.item = item + self.uri = uri diff --git a/docs/changelog.rst b/docs/changelog.rst index 0b5e195774..4863342739 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -158,6 +158,7 @@ New features: * :doc:`/plugins/convert`: Don't treat WAVE (`.wav`) files as lossy anymore when using the `never_convert_lossy_files` option. They will get transcoded like the other lossless formats. +* :doc:`/plugins/smartplaylist`: Add new config option `smartplaylist.fields`. Bug fixes: diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 0da487b032..06b4bd2564 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -313,6 +313,9 @@ Interoperability Automatically notifies `Subsonic`_ whenever the beets library changes. +`webm3u`_ + Serves the (:doc:`smartplaylist ` plugin generated) M3U + playlists via HTTP. .. _AURA: https://auraspec.readthedocs.io .. _Emby: https://emby.media @@ -321,6 +324,7 @@ Interoperability .. _Kodi: https://kodi.tv .. _Sonos: https://sonos.com .. _Subsonic: http://www.subsonic.org/ +.. _webm3u: https://github.com/mgoltzsche/beets-webm3u Miscellaneous ------------- diff --git a/docs/plugins/smartplaylist.rst b/docs/plugins/smartplaylist.rst index a40d188823..5bc727a9b6 100644 --- a/docs/plugins/smartplaylist.rst +++ b/docs/plugins/smartplaylist.rst @@ -123,6 +123,12 @@ other configuration options are: When this option is specified, the local path-related options ``prefix``, ``relative_to``, ``forward_slash`` and ``urlencode`` are ignored. - **output**: Specify the playlist format: m3u|m3u8. Default ``m3u``. +- **fields**: Specify the names of the additional item fields to export into + the playlist. This allows other tools such as the `webm3u`_ plugin to easily + access additional fields such as e.g. the ``id`` to e.g. rewrite item URIs. + To use this option, you must set the ``output`` option to ``m3u8``. + +.. _webm3u: https://github.com/mgoltzsche/beets-webm3u For many configuration options, there is a corresponding CLI option, e.g. ``--playlist-dir``, ``--relative-to``, ``--prefix``, ``--forward-slash``, diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index ee3bfb8ce7..be2f1708a6 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -241,6 +241,58 @@ def test_playlist_update_output_m3u8(self): + b"http://beets:8337/files/tagada.mp3\n", ) + def test_playlist_update_output_m3u8_attrs(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") + a = {"id": 123, "genre": "Fake Genre"} + i.__getitem__.side_effect = a.__getitem__ + 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"]["output"] = "m3u8" + config["smartplaylist"]["relative_to"] = False + config["smartplaylist"]["playlist_dir"] = py3_path(dir) + config["smartplaylist"]["fields"] = ["id", "genre"] + 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 id="123" genre="Fake Genre",Fake Artist - fake Title\n' + + b"/tagada.mp3\n", + ) + def test_playlist_update_uri_format(self): spl = SmartPlaylistPlugin()