diff --git a/beets/util/m3u.py b/beets/util/m3u.py new file mode 100644 index 000000000..9c961a291 --- /dev/null +++ b/beets/util/m3u.py @@ -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()) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 6ed6b6e54..ef6865597 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -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): diff --git a/docs/changelog.rst b/docs/changelog.rst index aaeb32e41..568d6022b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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: diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index 622a8f2cd..d70d354bf 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -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): diff --git a/test/rsrc/playlist.m3u b/test/rsrc/playlist.m3u new file mode 100644 index 000000000..cd0cdaad5 --- /dev/null +++ b/test/rsrc/playlist.m3u @@ -0,0 +1,3 @@ +#EXTM3U +/This/is/a/path/to_a_file.mp3 +/This/is/another/path/to_a_file.mp3 diff --git a/test/rsrc/playlist.m3u8 b/test/rsrc/playlist.m3u8 new file mode 100644 index 000000000..a3b00eb6a --- /dev/null +++ b/test/rsrc/playlist.m3u8 @@ -0,0 +1,3 @@ +#EXTM3U +/This/is/å/path/to_a_file.mp3 +/This/is/another/path/tö_a_file.mp3 diff --git a/test/rsrc/playlist_non_ext.m3u b/test/rsrc/playlist_non_ext.m3u new file mode 100644 index 000000000..a2d179010 --- /dev/null +++ b/test/rsrc/playlist_non_ext.m3u @@ -0,0 +1,2 @@ +/This/is/a/path/to_a_file.mp3 +/This/is/another/path/to_a_file.mp3 diff --git a/test/rsrc/playlist_windows.m3u8 b/test/rsrc/playlist_windows.m3u8 new file mode 100644 index 000000000..f75dfd74f --- /dev/null +++ b/test/rsrc/playlist_windows.m3u8 @@ -0,0 +1,3 @@ +#EXTM3U +x:\This\is\å\path\to_a_file.mp3 +x:\This\is\another\path\tö_a_file.mp3 diff --git a/test/test_convert.py b/test/test_convert.py index 7cdef4627..950711f76 100644 --- a/test/test_convert.py +++ b/test/test_convert.py @@ -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, diff --git a/test/test_m3ufile.py b/test/test_m3ufile.py new file mode 100644 index 000000000..a24dc6ca8 --- /dev/null +++ b/test/test_m3ufile.py @@ -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')