smartplaylist: add extm3u/extinf/m3u8 support

This is to be able to display meaningful metadata and search a playlist within a player without having to load the linked audio files of a playlist.
This commit is contained in:
Max Goltzsche 2023-12-13 20:57:07 +01:00
parent c1a232ec7b
commit b07a2e42f4
No known key found for this signature in database
GPG key ID: 364FA5A62B410BA4
4 changed files with 80 additions and 6 deletions

View file

@ -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")

View file

@ -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:

View file

@ -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``.

View file

@ -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<playlist>.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):