smartplaylist: allow exporting item fields

Allow generating extm3u 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`.
This commit is contained in:
Max Goltzsche 2024-02-23 03:52:31 +01:00
parent cc941df025
commit c0afd3eb3c
No known key found for this signature in database
GPG key ID: 364FA5A62B410BA4
5 changed files with 83 additions and 5 deletions

View file

@ -16,6 +16,7 @@
"""
import json
import os
from urllib.request import pathname2url
@ -46,6 +47,7 @@ class SmartPlaylistPlugin(BeetsPlugin):
"auto": True,
"playlists": [],
"uri_format": None,
"fields": [],
"forward_slash": False,
"prefix": "",
"urlencode": False,
@ -297,7 +299,7 @@ class SmartPlaylistPlugin(BeetsPlugin):
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 @@ class SmartPlaylistPlugin(BeetsPlugin):
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 @@ class SmartPlaylistPlugin(BeetsPlugin):
self._log.info(
"{0} playlists updated", len(self._matched_playlists)
)
class PlaylistItem:
def __init__(self, item, uri):
self.item = item
self.uri = uri

View file

@ -160,6 +160,7 @@ New features:
like the other lossless formats.
* Add support for `barcode` field.
:bug:`3172`
* :doc:`/plugins/smartplaylist`: Add new config option `smartplaylist.fields`.
Bug fixes:

View file

@ -313,6 +313,9 @@ Interoperability
Automatically notifies `Subsonic`_ whenever the beets
library changes.
`webm3u`_
Serves the (:doc:`smartplaylist <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
-------------

View file

@ -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 using e.g. the ``id`` field within other tools such
as the `webm3u`_ plugin.
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``,

View file

@ -241,6 +241,58 @@ class SmartPlaylistTest(_common.TestCase):
+ b"http://beets:8337/files/tagada.mp3\n",
)
def test_playlist_update_output_m3u8_fields(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": 456, "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<playlist>.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="456" genre="Fake Genre",Fake Artist - fake Title\n'
+ b"/tagada.mp3\n",
)
def test_playlist_update_uri_format(self):
spl = SmartPlaylistPlugin()