Skip to content

Commit

Permalink
smartplaylist: allow exporting item attributes
Browse files Browse the repository at this point in the history
Allow generating m3u8 playlists so that they contain additional item attributes 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. `subsonic:track:$id`.
  • Loading branch information
mgoltzsche committed Feb 24, 2024
1 parent dae5257 commit 63840af
Show file tree
Hide file tree
Showing 4 changed files with 75 additions and 5 deletions.
25 changes: 20 additions & 5 deletions beetsplug/smartplaylist.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"""


import json
import os
from urllib.request import pathname2url

Expand Down Expand Up @@ -46,6 +47,7 @@ def __init__(self):
"auto": True,
"playlists": [],
"uri_format": None,
"attributes": [],
"forward_slash": False,
"prefix": "",
"urlencode": False,
Expand Down Expand Up @@ -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:
Expand All @@ -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:
fields = []

Check failure on line 322 in beetsplug/smartplaylist.py

View workflow job for this annotation

GitHub Actions / lint

F841 local variable 'fields' is assigned to but never used
if m3u8:
keys = self.config["attributes"].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")

Expand All @@ -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
1 change: 1 addition & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ New features:
* :doc:`/plugins/smartplaylist`: Add new option `smartplaylist.uri_format`.
* Sorted the default configuration file into categories.
:bug:`4987`
* :doc:`/plugins/smartplaylist`: Add new config option `smartplaylist.attributes`.

Bug fixes:

Expand Down
2 changes: 2 additions & 0 deletions docs/plugins/smartplaylist.rst
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ other configuration options are:
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.
- **attributes**: Specify the names of the additional item attributes to write
into the playlist.
- **output**: Specify the playlist format: m3u|m3u8. Default ``m3u``.

For many configuration options, there is a corresponding CLI option, e.g.
Expand Down
52 changes: 52 additions & 0 deletions test/plugins/test_smartplaylist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).id = PropertyMock(return_value=123)
type(i).artist = PropertyMock(return_value="Fake Artist")
type(i).title = PropertyMock(return_value="fake Title")
type(i).genre = PropertyMock(return_value="Fake Genre")
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<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"]["attributes"] = ["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"http://beets:8337/files/tagada.mp3\n",
)

def test_playlist_update_uri_format(self):
spl = SmartPlaylistPlugin()

Expand Down

0 comments on commit 63840af

Please sign in to comment.