Merge pull request #4399 from JOJ0/convert_playlist

convert: New feature "Write m3u playlist to destination folder"
This commit is contained in:
J0J0 Todos 2023-04-03 07:21:06 +02:00 committed by GitHub
commit 8705457d24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 321 additions and 5 deletions

93
beets/util/m3u.py Normal file
View file

@ -0,0 +1,93 @@
# This file is part of beets.
# Copyright 2022, J0J0 Todos.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Provides utilities to read, write and manipulate m3u playlist files."""
import traceback
from beets.util import syspath, normpath, mkdirall, FilesystemError
class EmptyPlaylistError(Exception):
"""Raised when a playlist file without media files is saved or loaded."""
pass
class M3UFile():
"""Reads and writes m3u or m3u8 playlist files."""
def __init__(self, path):
"""``path`` is the absolute path to the playlist file.
The playlist file type, m3u or m3u8 is determined by 1) the ending
being m3u8 and 2) the file paths contained in the list being utf-8
encoded. Since the list is passed from the outside, this is currently
out of control of this class.
"""
self.path = path
self.extm3u = False
self.media_list = []
def load(self):
"""Reads the m3u file from disk and sets the object's attributes."""
pl_normpath = normpath(self.path)
try:
with open(syspath(pl_normpath), "rb") as pl_file:
raw_contents = pl_file.readlines()
except OSError as exc:
raise FilesystemError(exc, 'read', (pl_normpath, ),
traceback.format_exc())
self.extm3u = True if raw_contents[0].rstrip() == b"#EXTM3U" else False
for line in raw_contents[1:]:
if line.startswith(b"#"):
# Support for specific EXTM3U comments could be added here.
continue
self.media_list.append(normpath(line.rstrip()))
if not self.media_list:
raise EmptyPlaylistError
def set_contents(self, media_list, extm3u=True):
"""Sets self.media_list to a list of media file paths.
Also sets additional flags, changing the final m3u-file's format.
``media_list`` is a list of paths to media files that should be added
to the playlist (relative or absolute paths, that's the responsibility
of the caller). By default the ``extm3u`` flag is set, to ensure a
save-operation writes an m3u-extended playlist (comment "#EXTM3U" at
the top of the file).
"""
self.media_list = media_list
self.extm3u = extm3u
def write(self):
"""Writes the m3u file to disk.
Handles the creation of potential parent directories.
"""
header = [b"#EXTM3U"] if self.extm3u else []
if not self.media_list:
raise EmptyPlaylistError
contents = header + self.media_list
pl_normpath = normpath(self.path)
mkdirall(pl_normpath)
try:
with open(syspath(pl_normpath), "wb") as pl_file:
for line in contents:
pl_file.write(line + b'\n')
pl_file.write(b'\n') # Final linefeed to prevent noeol file.
except OSError as exc:
raise FilesystemError(exc, 'create', (pl_normpath, ),
traceback.format_exc())

View file

@ -31,6 +31,7 @@ from beets import art
from beets.util.artresizer import ArtResizer
from beets.library import parse_query_string
from beets.library import Item
from beets.util.m3u import M3UFile
_fs_lock = threading.Lock()
_temp_files = [] # Keep track of temporary transcoded files for deletion.
@ -149,6 +150,7 @@ class ConvertPlugin(BeetsPlugin):
'copy_album_art': False,
'album_art_maxwidth': 0,
'delete_originals': False,
'playlist': None,
})
self.early_import_stages = [self.auto_convert, self.auto_convert_keep]
@ -177,6 +179,15 @@ class ConvertPlugin(BeetsPlugin):
dest='hardlink',
help='hardlink files that do not \
need transcoding. Overrides --link.')
cmd.parser.add_option('-m', '--playlist', action='store',
help='''create an m3u8 playlist file containing
the converted files. The playlist file will be
saved below the destination directory, thus
PLAYLIST could be a file name or a relative path.
To ensure a working playlist when transferred to
a different computer, or opened from an external
drive, relative paths pointing to media files
will be used.''')
cmd.parser.add_album_option()
cmd.func = self.convert_func
return [cmd]
@ -436,7 +447,7 @@ class ConvertPlugin(BeetsPlugin):
def convert_func(self, lib, opts, args):
(dest, threads, path_formats, fmt,
pretend, hardlink, link) = self._get_opts_and_config(opts)
pretend, hardlink, link, playlist) = self._get_opts_and_config(opts)
if opts.album:
albums = lib.albums(ui.decargs(args))
@ -461,8 +472,26 @@ class ConvertPlugin(BeetsPlugin):
self.copy_album_art(album, dest, path_formats, pretend,
link, hardlink)
self._parallel_convert(dest, opts.keep_new, path_formats, fmt,
pretend, link, hardlink, threads, items)
self._parallel_convert(dest, opts.keep_new, path_formats, fmt, pretend,
link, hardlink, threads, items)
if playlist:
# Playlist paths are understood as relative to the dest directory.
pl_normpath = util.normpath(playlist)
pl_dir = os.path.dirname(pl_normpath)
self._log.info("Creating playlist file {0}", pl_normpath)
# Generates a list of paths to media files, ensures the paths are
# relative to the playlist's location and translates the unicode
# strings we get from item.destination to bytes.
items_paths = [
os.path.relpath(util.bytestring_path(item.destination(
basedir=dest, path_formats=path_formats, fragment=False
)), pl_dir) for item in items
]
if not pretend:
m3ufile = M3UFile(playlist)
m3ufile.set_contents(items_paths)
m3ufile.write()
def convert_on_import(self, lib, item):
"""Transcode a file automatically after it is imported into the
@ -544,6 +573,10 @@ class ConvertPlugin(BeetsPlugin):
fmt = opts.format or self.config['format'].as_str().lower()
playlist = opts.playlist or self.config['playlist'].get()
if playlist is not None:
playlist = os.path.join(dest, util.bytestring_path(playlist))
if opts.pretend is not None:
pretend = opts.pretend
else:
@ -559,7 +592,8 @@ class ConvertPlugin(BeetsPlugin):
hardlink = self.config['hardlink'].get(bool)
link = self.config['link'].get(bool)
return dest, threads, path_formats, fmt, pretend, hardlink, link
return (dest, threads, path_formats, fmt, pretend, hardlink, link,
playlist)
def _parallel_convert(self, dest, keep_new, path_formats, fmt,
pretend, link, hardlink, threads, items):

View file

@ -70,6 +70,9 @@ New features:
enabled via the :ref:`musicbrainz.external_ids` options, release ID's will be
extracted from those URL's and imported to the library.
:bug:`4220`
* :doc:`/plugins/convert`: Add support for generating m3u8 playlists together
with converted media files.
:bug:`4373`
Bug fixes:

View file

@ -4,7 +4,8 @@ Convert Plugin
The ``convert`` plugin lets you convert parts of your collection to a
directory of your choice, transcoding audio and embedding album art along the
way. It can transcode to and from any format using a configurable command
line.
line. Optionally an m3u playlist file containing all the converted files can be
saved to the destination path.
Installation
@ -54,6 +55,18 @@ instead, passing ``-H`` (``--hardlink``) creates hard links.
Note that album art embedding is disabled for files that are linked.
Refer to the ``link`` and ``hardlink`` options below.
The ``-m`` (or ``--playlist``) option enables the plugin to create an m3u8
playlist file in the destination folder given by the ``-d`` (``--dest``) option
or the ``dest`` configuration. The path to the playlist file can either be
absolute or relative to the ``dest`` directory. The contents will always be
relative paths to media files, which tries to ensure compatibility when read
from external drives or on computers other than the one used for the
conversion. There is one caveat though: A list generated on Unix/macOS can't be
read on Windows and vice versa.
Depending on the beets user's settings a generated playlist potentially could
contain unicode characters. This is supported, playlists are written in [m3u8
format](https://en.wikipedia.org/wiki/M3U#M3U8).
Configuration
-------------
@ -124,6 +137,12 @@ file. The available options are:
Default: ``false``.
- **delete_originals**: Transcoded files will be copied or moved to their destination, depending on the import configuration. By default, the original files are not modified by the plugin. This option deletes the original files after the transcoding step has completed.
Default: ``false``.
- **playlist**: The name of a playlist file that should be written on each run
of the plugin. A relative file path (e.g `playlists/mylist.m3u8`) is allowed
as well. The final destination of the playlist file will always be relative
to the destination path (``dest``, ``--dest``, ``-d``). This configuration is
overridden by the ``-m`` (``--playlist``) command line option.
Default: none.
You can also configure the format to use for transcoding (see the next
section):

3
test/rsrc/playlist.m3u Normal file
View file

@ -0,0 +1,3 @@
#EXTM3U
/This/is/a/path/to_a_file.mp3
/This/is/another/path/to_a_file.mp3

3
test/rsrc/playlist.m3u8 Normal file
View file

@ -0,0 +1,3 @@
#EXTM3U
/This/is/å/path/to_a_file.mp3
/This/is/another/path/tö_a_file.mp3

View file

@ -0,0 +1,2 @@
/This/is/a/path/to_a_file.mp3
/This/is/another/path/to_a_file.mp3

View file

@ -0,0 +1,3 @@
#EXTM3U
x:\This\is\å\path\to_a_file.mp3
x:\This\is\another\path\tö_a_file.mp3

View file

@ -293,6 +293,17 @@ class ConvertCliTest(unittest.TestCase, TestHelper, ConvertCommand):
converted = os.path.join(self.convert_dest, b'converted.ogg')
self.assertNoFileTag(converted, 'ogg')
def test_playlist(self):
with control_stdin('y'):
self.run_convert('--playlist', 'playlist.m3u8')
m3u_created = os.path.join(self.convert_dest, b'playlist.m3u8')
self.assertTrue(os.path.exists(m3u_created))
def test_playlist_pretend(self):
self.run_convert('--playlist', 'playlist.m3u8', '--pretend')
m3u_created = os.path.join(self.convert_dest, b'playlist.m3u8')
self.assertFalse(os.path.exists(m3u_created))
@_common.slow_test()
class NeverConvertLossyFilesTest(unittest.TestCase, TestHelper,

145
test/test_m3ufile.py Normal file
View file

@ -0,0 +1,145 @@
# This file is part of beets.
# Copyright 2022, J0J0 Todos.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Testsuite for the M3UFile class."""
from os import path
from tempfile import mkdtemp
from shutil import rmtree
import unittest
import sys
from beets.util import bytestring_path
from beets.util.m3u import M3UFile, EmptyPlaylistError
from test._common import RSRC
class M3UFileTest(unittest.TestCase):
"""Tests the M3UFile class."""
def test_playlist_write_empty(self):
"""Test whether saving an empty playlist file raises an error."""
tempdir = bytestring_path(mkdtemp())
the_playlist_file = path.join(tempdir, b'playlist.m3u8')
m3ufile = M3UFile(the_playlist_file)
with self.assertRaises(EmptyPlaylistError):
m3ufile.write()
rmtree(tempdir)
def test_playlist_write(self):
"""Test saving ascii paths to a playlist file."""
tempdir = bytestring_path(mkdtemp())
the_playlist_file = path.join(tempdir, b'playlist.m3u')
m3ufile = M3UFile(the_playlist_file)
m3ufile.set_contents([
bytestring_path('/This/is/a/path/to_a_file.mp3'),
bytestring_path('/This/is/another/path/to_a_file.mp3')
])
m3ufile.write()
self.assertTrue(path.exists(the_playlist_file))
rmtree(tempdir)
def test_playlist_write_unicode(self):
"""Test saving unicode paths to a playlist file."""
tempdir = bytestring_path(mkdtemp())
the_playlist_file = path.join(tempdir, b'playlist.m3u8')
m3ufile = M3UFile(the_playlist_file)
m3ufile.set_contents([
bytestring_path('/This/is/å/path/to_a_file.mp3'),
bytestring_path('/This/is/another/path/tö_a_file.mp3')
])
m3ufile.write()
self.assertTrue(path.exists(the_playlist_file))
rmtree(tempdir)
@unittest.skipUnless(sys.platform == 'win32', 'win32')
def test_playlist_write_and_read_unicode_windows(self):
"""Test saving unicode paths to a playlist file on Windows."""
tempdir = bytestring_path(mkdtemp())
the_playlist_file = path.join(tempdir,
b'playlist_write_and_read_windows.m3u8')
m3ufile = M3UFile(the_playlist_file)
m3ufile.set_contents([
bytestring_path(r"x:\This\is\å\path\to_a_file.mp3"),
bytestring_path(r"x:\This\is\another\path\tö_a_file.mp3")
])
m3ufile.write()
self.assertTrue(path.exists(the_playlist_file))
m3ufile_read = M3UFile(the_playlist_file)
m3ufile_read.load()
self.assertEquals(
m3ufile.media_list[0],
bytestring_path(
path.join('x:\\', 'This', 'is', 'å', 'path', 'to_a_file.mp3'))
)
self.assertEquals(
m3ufile.media_list[1],
bytestring_path(r"x:\This\is\another\path\tö_a_file.mp3"),
bytestring_path(path.join(
'x:\\', 'This', 'is', 'another', 'path', 'tö_a_file.mp3'))
)
rmtree(tempdir)
@unittest.skipIf(sys.platform == 'win32', 'win32')
def test_playlist_load_ascii(self):
"""Test loading ascii paths from a playlist file."""
the_playlist_file = path.join(RSRC, b'playlist.m3u')
m3ufile = M3UFile(the_playlist_file)
m3ufile.load()
self.assertEqual(m3ufile.media_list[0],
bytestring_path('/This/is/a/path/to_a_file.mp3'))
@unittest.skipIf(sys.platform == 'win32', 'win32')
def test_playlist_load_unicode(self):
"""Test loading unicode paths from a playlist file."""
the_playlist_file = path.join(RSRC, b'playlist.m3u8')
m3ufile = M3UFile(the_playlist_file)
m3ufile.load()
self.assertEqual(m3ufile.media_list[0],
bytestring_path('/This/is/å/path/to_a_file.mp3'))
@unittest.skipUnless(sys.platform == 'win32', 'win32')
def test_playlist_load_unicode_windows(self):
"""Test loading unicode paths from a playlist file."""
the_playlist_file = path.join(RSRC, b'playlist_windows.m3u8')
winpath = bytestring_path(path.join(
'x:\\', 'This', 'is', 'å', 'path', 'to_a_file.mp3'))
m3ufile = M3UFile(the_playlist_file)
m3ufile.load()
self.assertEqual(
m3ufile.media_list[0],
winpath
)
def test_playlist_load_extm3u(self):
"""Test loading a playlist with an #EXTM3U header."""
the_playlist_file = path.join(RSRC, b'playlist.m3u')
m3ufile = M3UFile(the_playlist_file)
m3ufile.load()
self.assertTrue(m3ufile.extm3u)
def test_playlist_load_non_extm3u(self):
"""Test loading a playlist without an #EXTM3U header."""
the_playlist_file = path.join(RSRC, b'playlist_non_ext.m3u')
m3ufile = M3UFile(the_playlist_file)
m3ufile.load()
self.assertFalse(m3ufile.extm3u)
def suite():
"""This testsuite's main function."""
return unittest.TestLoader().loadTestsFromName(__name__)
if __name__ == '__main__':
unittest.main(defaultTest='suite')