From 861bc69df55cb3d4d20da6082878542ff42d85d8 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Sat, 18 Jun 2022 06:36:20 +0200 Subject: [PATCH 01/46] convert: Add a quick & dirty m3u playlist feature --- beetsplug/convert.py | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 6ed6b6e54..1f6682474 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -149,6 +149,7 @@ class ConvertPlugin(BeetsPlugin): 'copy_album_art': False, 'album_art_maxwidth': 0, 'delete_originals': False, + 'playlist': '', }) self.early_import_stages = [self.auto_convert, self.auto_convert_keep] @@ -177,6 +178,8 @@ class ConvertPlugin(BeetsPlugin): dest='hardlink', help='hardlink files that do not \ need transcoding. Overrides --link.') + cmd.parser.add_option('-m', '--playlist', action='store', + help='set the name of the playlist file to be created') cmd.parser.add_album_option() cmd.func = self.convert_func return [cmd] @@ -257,7 +260,7 @@ class ConvertPlugin(BeetsPlugin): util.displayable_path(source)) def convert_item(self, dest_dir, keep_new, path_formats, fmt, - pretend=False, link=False, hardlink=False): + pretend=False, link=False, hardlink=False, playlist=''): """A pipeline thread that converts `Item` objects from a library. """ @@ -282,6 +285,13 @@ class ConvertPlugin(BeetsPlugin): dest = replace_ext(dest, ext) converted = dest + # Quick, dirty, playlist + # converted_filename = Path(str(converted)).name + converted_filename = os.path.basename(converted) + self._log.info("Appending to playlist file {0}".format(playlist)) + with open(playlist, "ab") as playlist_file: + playlist_file.write(converted_filename + b"\n") + # Ensure that only one thread tries to create directories at a # time. (The existence check is not atomic with the directory # creation inside this function.) @@ -436,7 +446,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 +471,16 @@ class ConvertPlugin(BeetsPlugin): self.copy_album_art(album, dest, path_formats, pretend, link, hardlink) + if playlist: + print("################") + print(f"Playlist: {playlist}") + print("################") + if not pretend: + with open(playlist, "w") as playlist_file: + playlist_file.write("#EXTM3U" + "\n") + self._parallel_convert(dest, opts.keep_new, path_formats, fmt, - pretend, link, hardlink, threads, items) + pretend, link, hardlink, threads, items, playlist) def convert_on_import(self, lib, item): """Transcode a file automatically after it is imported into the @@ -544,6 +562,8 @@ class ConvertPlugin(BeetsPlugin): fmt = opts.format or self.config['format'].as_str().lower() + playlist = opts.playlist or self.config['playlist'].get() + if opts.pretend is not None: pretend = opts.pretend else: @@ -559,10 +579,11 @@ 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): + pretend, link, hardlink, threads, items, playlist): """Run the convert_item function for every items on as many thread as defined in threads """ @@ -572,7 +593,8 @@ class ConvertPlugin(BeetsPlugin): fmt, pretend, link, - hardlink) + hardlink, + playlist) for _ in range(threads)] pipe = util.pipeline.Pipeline([iter(items), convert]) pipe.run_parallel() From d448e0c4de6f5f3d81ad9a2de874a515d6debeb8 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Mon, 4 Jul 2022 14:44:35 +0200 Subject: [PATCH 02/46] convert: Refine and fix playlist feature - Improve --help text - Use unicode instead of bytes when adding media file paths to the playlist file. - The "standard" (?) of m3u8 defines that unicode should ensure support of special characters in media file names. util.displayable_path() is used to do the conversion from bytes. We save everything in bytes in the config since it seemes to be the way this plugin or beets in general likes to save paths. - Join dest and playlist in the config reader method already to have it ready in both methods that require the full path to the playlist file. --- beetsplug/convert.py | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 1f6682474..35f5b3c1c 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -149,7 +149,7 @@ class ConvertPlugin(BeetsPlugin): 'copy_album_art': False, 'album_art_maxwidth': 0, 'delete_originals': False, - 'playlist': '', + 'playlist': None, }) self.early_import_stages = [self.auto_convert, self.auto_convert_keep] @@ -179,7 +179,14 @@ class ConvertPlugin(BeetsPlugin): help='hardlink files that do not \ need transcoding. Overrides --link.') cmd.parser.add_option('-m', '--playlist', action='store', - help='set the name of the playlist file to be created') + help='''the name of an m3u8 playlist file to + be created in the root of the destination folder. + The m3u8 format ensures special characters + support by using unicode to save media file + paths. Relative paths are used to point to media + files ensuring a working playlist when + transferred to a different computer (eg. when + opened from an external drive).''') cmd.parser.add_album_option() cmd.func = self.convert_func return [cmd] @@ -260,7 +267,7 @@ class ConvertPlugin(BeetsPlugin): util.displayable_path(source)) def convert_item(self, dest_dir, keep_new, path_formats, fmt, - pretend=False, link=False, hardlink=False, playlist=''): + pretend=False, link=False, hardlink=False, playlist=None): """A pipeline thread that converts `Item` objects from a library. """ @@ -285,12 +292,18 @@ class ConvertPlugin(BeetsPlugin): dest = replace_ext(dest, ext) converted = dest - # Quick, dirty, playlist - # converted_filename = Path(str(converted)).name - converted_filename = os.path.basename(converted) - self._log.info("Appending to playlist file {0}".format(playlist)) - with open(playlist, "ab") as playlist_file: - playlist_file.write(converted_filename + b"\n") + # When the playlist argument is passed, add the current filename to + # an m3u8 playlist file located in the destination folder. + if playlist: + self._log.debug( + "Appending to playlist file {0}", + util.displayable_path(playlist) + ) + with open(playlist, "a") as playlist_file: + # The classic m3u format doesn't support special characters + # in media file paths, thus we use the m3u8 format which + # requires media file paths to be unicode. + playlist_file.write(util.displayable_path(dest) + "\n") # Ensure that only one thread tries to create directories at a # time. (The existence check is not atomic with the directory @@ -472,11 +485,9 @@ class ConvertPlugin(BeetsPlugin): link, hardlink) if playlist: - print("################") - print(f"Playlist: {playlist}") - print("################") + self._log.info("Creating playlist file: {0}", playlist) if not pretend: - with open(playlist, "w") as playlist_file: + with open(os.path.join(dest, playlist), "w") as playlist_file: playlist_file.write("#EXTM3U" + "\n") self._parallel_convert(dest, opts.keep_new, path_formats, fmt, @@ -563,6 +574,8 @@ 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 From fd8fe69738c1b2e56d4650364ecd4e90dea3f0af Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Mon, 4 Jul 2022 15:09:31 +0200 Subject: [PATCH 03/46] convert: Playlist feature linting fixes --- beetsplug/convert.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 35f5b3c1c..2680d3951 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -490,8 +490,8 @@ class ConvertPlugin(BeetsPlugin): with open(os.path.join(dest, playlist), "w") as playlist_file: playlist_file.write("#EXTM3U" + "\n") - self._parallel_convert(dest, opts.keep_new, path_formats, fmt, - pretend, link, hardlink, threads, items, playlist) + self._parallel_convert(dest, opts.keep_new, path_formats, fmt, pretend, + link, hardlink, threads, items, playlist) def convert_on_import(self, lib, item): """Transcode a file automatically after it is imported into the @@ -592,8 +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, playlist + 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, playlist): From 16e25bb61bd821fb28f602b3aca27f4ec3f5ee9a Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Mon, 4 Jul 2022 16:14:33 +0200 Subject: [PATCH 04/46] convert: playlist feature: Fix relative paths pointing to media files in playlist. Also refine code comment and move to a better fitting place. --- beetsplug/convert.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 2680d3951..082124b8c 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -299,11 +299,16 @@ class ConvertPlugin(BeetsPlugin): "Appending to playlist file {0}", util.displayable_path(playlist) ) + # The classic m3u format doesn't support special characters in + # media file paths, thus we use the m3u8 format which requires + # media file paths to be unicode. Additionally we use relative + # paths to ensure readability of the playlist on remote + # computers. + dest_relative = util.displayable_path(dest).replace( + util.displayable_path(dest_dir) + os.sep, "" + ) with open(playlist, "a") as playlist_file: - # The classic m3u format doesn't support special characters - # in media file paths, thus we use the m3u8 format which - # requires media file paths to be unicode. - playlist_file.write(util.displayable_path(dest) + "\n") + playlist_file.write(dest_relative + "\n") # Ensure that only one thread tries to create directories at a # time. (The existence check is not atomic with the directory From c0b1bc986705d75a36a90da4f7999b39fcf420c0 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Wed, 6 Jul 2022 08:36:32 +0200 Subject: [PATCH 05/46] convert: playlist feature: Better relative path gen Use Item.destination method for generation of relative paths to media files in playlist. The fragment keyword enables returning the path as unicode instead of bytes, let's keep that in mind. --- beetsplug/convert.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 082124b8c..5d6f2ce07 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -304,8 +304,10 @@ class ConvertPlugin(BeetsPlugin): # media file paths to be unicode. Additionally we use relative # paths to ensure readability of the playlist on remote # computers. - dest_relative = util.displayable_path(dest).replace( - util.displayable_path(dest_dir) + os.sep, "" + dest_relative = item.destination( + basedir=dest_dir, + path_formats=path_formats, + fragment=True ) with open(playlist, "a") as playlist_file: playlist_file.write(dest_relative + "\n") From d589e77adea920e47c5ddc6123de2945f8d993c3 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Sun, 10 Jul 2022 12:25:51 +0200 Subject: [PATCH 06/46] convert: playlist: Fix redundant join path It's done in _get_opts_and_config already. --- beetsplug/convert.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 5d6f2ce07..734f393bc 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -494,7 +494,7 @@ class ConvertPlugin(BeetsPlugin): if playlist: self._log.info("Creating playlist file: {0}", playlist) if not pretend: - with open(os.path.join(dest, playlist), "w") as playlist_file: + with open(playlist, "w") as playlist_file: playlist_file.write("#EXTM3U" + "\n") self._parallel_convert(dest, opts.keep_new, path_formats, fmt, pretend, From c251ed19c4162803e4de74bce4b0d792816ef0e9 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Sun, 10 Jul 2022 15:18:42 +0200 Subject: [PATCH 07/46] convert: playlist: Generate m3u file in one batch to avoid any possible interference with the threaded file conversion mechanism. --- beetsplug/convert.py | 44 ++++++++++++++++++-------------------------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 734f393bc..ebac336d5 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -267,7 +267,7 @@ class ConvertPlugin(BeetsPlugin): util.displayable_path(source)) def convert_item(self, dest_dir, keep_new, path_formats, fmt, - pretend=False, link=False, hardlink=False, playlist=None): + pretend=False, link=False, hardlink=False): """A pipeline thread that converts `Item` objects from a library. """ @@ -292,26 +292,6 @@ class ConvertPlugin(BeetsPlugin): dest = replace_ext(dest, ext) converted = dest - # When the playlist argument is passed, add the current filename to - # an m3u8 playlist file located in the destination folder. - if playlist: - self._log.debug( - "Appending to playlist file {0}", - util.displayable_path(playlist) - ) - # The classic m3u format doesn't support special characters in - # media file paths, thus we use the m3u8 format which requires - # media file paths to be unicode. Additionally we use relative - # paths to ensure readability of the playlist on remote - # computers. - dest_relative = item.destination( - basedir=dest_dir, - path_formats=path_formats, - fragment=True - ) - with open(playlist, "a") as playlist_file: - playlist_file.write(dest_relative + "\n") - # Ensure that only one thread tries to create directories at a # time. (The existence check is not atomic with the directory # creation inside this function.) @@ -492,13 +472,26 @@ class ConvertPlugin(BeetsPlugin): link, hardlink) if playlist: + # When playlist arg is passed create an m3u8 file in dest folder. + # + # The classic m3u format doesn't support special characters in + # media file paths, thus we use the m3u8 format which requires + # media file paths to be unicode. Additionally we use relative + # paths to ensure readability of the playlist on remote + # computers. self._log.info("Creating playlist file: {0}", playlist) + items_paths = [ + item.destination( + basedir=dest, path_formats=path_formats, fragment=True + ) for item in items + ] + items_paths = ["#EXTM3U"] + items_paths if not pretend: with open(playlist, "w") as playlist_file: - playlist_file.write("#EXTM3U" + "\n") + playlist_file.writelines('\n'.join(items_paths)) self._parallel_convert(dest, opts.keep_new, path_formats, fmt, pretend, - link, hardlink, threads, items, playlist) + link, hardlink, threads, items) def convert_on_import(self, lib, item): """Transcode a file automatically after it is imported into the @@ -603,7 +596,7 @@ class ConvertPlugin(BeetsPlugin): playlist) def _parallel_convert(self, dest, keep_new, path_formats, fmt, - pretend, link, hardlink, threads, items, playlist): + pretend, link, hardlink, threads, items): """Run the convert_item function for every items on as many thread as defined in threads """ @@ -613,8 +606,7 @@ class ConvertPlugin(BeetsPlugin): fmt, pretend, link, - hardlink, - playlist) + hardlink) for _ in range(threads)] pipe = util.pipeline.Pipeline([iter(items), convert]) pipe.run_parallel() From 5dfff5000585cb987817c8b11c9ae2d48492fd06 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Wed, 13 Jul 2022 08:49:53 +0200 Subject: [PATCH 08/46] convert: playlist: Refactor m3u writing to class and also implement a currently untested load() method. --- beets/util/__init__.py | 50 ++++++++++++++++++++++++++++++++++++++++++ beetsplug/convert.py | 7 +++--- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 2319890a3..3d27edd5e 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -135,6 +135,56 @@ class MoveOperation(Enum): REFLINK_AUTO = 5 +class M3UFile(): + def __init__(self, path): + """Reads and writes m3u or m3u8 playlist files. + + ``path`` is the full 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. + """ + with open(self.name, "r") as playlist_file: + raw_contents = playlist_file.readlines() + self.extm3u = True if raw_contents[0] == "#EXTM3U" else False + for line in raw_contents[1:]: + if line.startswith("#"): + # Some EXTM3U comment, do something. FIXME + continue + self.media_list.append(line) + + def set_contents(self, media_files, extm3u=True): + """Sets self.media_files to a list of media file paths, + + and sets additional flags, changing the final m3u-file's format. + + ``media_files`` 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_files = media_files + self.extm3u = extm3u + + def write(self): + """Writes the m3u file to disk.""" + header = ["#EXTM3U"] if self.extm3u else [] + contents = header + self.media_files + with open(self.path, "w") as playlist_file: + playlist_file.writelines('\n'.join(contents)) + playlist_file.write('\n') # Final linefeed to prevent noeol file. + + def normpath(path): """Provide the canonical form of the path suitable for storing in the database. diff --git a/beetsplug/convert.py b/beetsplug/convert.py index ebac336d5..2856363ab 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 import M3UFile _fs_lock = threading.Lock() _temp_files = [] # Keep track of temporary transcoded files for deletion. @@ -485,10 +486,10 @@ class ConvertPlugin(BeetsPlugin): basedir=dest, path_formats=path_formats, fragment=True ) for item in items ] - items_paths = ["#EXTM3U"] + items_paths if not pretend: - with open(playlist, "w") as playlist_file: - playlist_file.writelines('\n'.join(items_paths)) + m3ufile = M3UFile(playlist) + m3ufile.set_contents(items_paths) + m3ufile.write() self._parallel_convert(dest, opts.keep_new, path_formats, fmt, pretend, link, hardlink, threads, items) From 9930a5da5939acc62d6fe64d949dc11b3777331b Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Wed, 10 Aug 2022 07:17:56 +0200 Subject: [PATCH 09/46] convert: playlist: Test playlist existence Broken commit around writing a unittest that checks for existence of an m3u file after convert has been called with --playlist option. --- test/test_convert.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/test/test_convert.py b/test/test_convert.py index 7cdef4627..f114a04dc 100644 --- a/test/test_convert.py +++ b/test/test_convert.py @@ -184,8 +184,9 @@ class ConvertCliTest(unittest.TestCase, TestHelper, ConvertCommand): } def tearDown(self): - self.unload_plugins() - self.teardown_beets() + pass + #self.unload_plugins() + #self.teardown_beets() def test_convert(self): with control_stdin('y'): @@ -293,6 +294,19 @@ 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') + converted = os.path.join(self.convert_dest, b'converted.mp3') + self.assertFileTag(converted, 'mp3') + 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, From e41525adbc1948683f66d7077a99664c460e87ee Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Fri, 12 Aug 2022 06:48:06 +0200 Subject: [PATCH 10/46] convert: playlist: Remove clutter from test - Remove comments in tearDown - Don't test for converted.mp3, it's done in a separate test already, we only want to test for the playlist file here. --- test/test_convert.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/test/test_convert.py b/test/test_convert.py index f114a04dc..950711f76 100644 --- a/test/test_convert.py +++ b/test/test_convert.py @@ -184,9 +184,8 @@ class ConvertCliTest(unittest.TestCase, TestHelper, ConvertCommand): } def tearDown(self): - pass - #self.unload_plugins() - #self.teardown_beets() + self.unload_plugins() + self.teardown_beets() def test_convert(self): with control_stdin('y'): @@ -297,8 +296,6 @@ class ConvertCliTest(unittest.TestCase, TestHelper, ConvertCommand): def test_playlist(self): with control_stdin('y'): self.run_convert('--playlist', 'playlist.m3u8') - converted = os.path.join(self.convert_dest, b'converted.mp3') - self.assertFileTag(converted, 'mp3') m3u_created = os.path.join(self.convert_dest, b'playlist.m3u8') self.assertTrue(os.path.exists(m3u_created)) From 55b386375ae0426fd4c0742644c230e25fdc4a7d Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Fri, 12 Aug 2022 06:55:20 +0200 Subject: [PATCH 11/46] convert: playlist: Move m3u creation after conversions - Move the creation of the playlist file to the very end, right after self._parallel_convert, in the convert plugin's main function. - In the test code, the destination directory is created when the conversion happens, thus this fixes test_playlist and doesn't hurt the feature - The playlist creation can as well be the very last step in the process. --- beetsplug/convert.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 2856363ab..ab6e4c420 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -472,6 +472,9 @@ 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) + if playlist: # When playlist arg is passed create an m3u8 file in dest folder. # @@ -491,9 +494,6 @@ class ConvertPlugin(BeetsPlugin): m3ufile.set_contents(items_paths) m3ufile.write() - self._parallel_convert(dest, opts.keep_new, path_formats, fmt, pretend, - link, hardlink, threads, items) - def convert_on_import(self, lib, item): """Transcode a file automatically after it is imported into the library. From ba3740c8fe41b305d7ede50031e177f8ff2c66dd Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Fri, 12 Aug 2022 08:56:31 +0200 Subject: [PATCH 12/46] convert: playlist: Fix filename attr in load method --- beets/util/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 3d27edd5e..58eb47034 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -153,7 +153,7 @@ class M3UFile(): def load(self): """Reads the m3u file from disk and sets the object's attributes. """ - with open(self.name, "r") as playlist_file: + with open(self.path, "r") as playlist_file: raw_contents = playlist_file.readlines() self.extm3u = True if raw_contents[0] == "#EXTM3U" else False for line in raw_contents[1:]: From 0cbf91e4d8815f844cd560c7ab7e0e377ff8dd15 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Fri, 12 Aug 2022 08:56:44 +0200 Subject: [PATCH 13/46] convert: playlist: Add test_m3ufile and fixtures Add several tests checking loading and saving unicode and regular ascii text playlist files. --- test/rsrc/playlist.m3u | 3 ++ test/rsrc/playlist.m3u8 | 3 ++ test/test_m3ufile.py | 81 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 test/rsrc/playlist.m3u create mode 100644 test/rsrc/playlist.m3u8 create mode 100644 test/test_m3ufile.py 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/test_m3ufile.py b/test/test_m3ufile.py new file mode 100644 index 000000000..4f5807b6b --- /dev/null +++ b/test/test_m3ufile.py @@ -0,0 +1,81 @@ +# This file is part of beets. +# Copyright 2016, Johannes Tiefenbacher. +# +# 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. + + +from os import path, remove +from tempfile import mkdtemp +from shutil import rmtree +import unittest + +# from unittest.mock import Mock, MagicMock + +from beets.util import M3UFile +from beets.util import syspath, bytestring_path, py3_path, CHAR_REPLACE +from test._common import RSRC + + +class M3UFileTest(unittest.TestCase): + def test_playlist_write_empty(self): + tempdir = bytestring_path(mkdtemp()) + the_playlist_file = path.join(tempdir, b'playlist.m3u8') + m3ufile = M3UFile(the_playlist_file) + m3ufile.write() + self.assertFalse(path.exists(the_playlist_file)) + rmtree(tempdir) + + def test_playlist_write(self): + tempdir = bytestring_path(mkdtemp()) + the_playlist_file = path.join(tempdir, b'playlist.m3u') + m3ufile = M3UFile(the_playlist_file) + m3ufile.set_contents([ + '/This/is/a/path/to_a_file.mp3', + '/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): + tempdir = bytestring_path(mkdtemp()) + the_playlist_file = path.join(tempdir, b'playlist.m3u8') + m3ufile = M3UFile(the_playlist_file) + m3ufile.set_contents([ + '/This/is/å/path/to_a_file.mp3', + '/This/is/another/path/tö_a_file.mp3', + ]) + m3ufile.write() + self.assertTrue(path.exists(the_playlist_file)) + rmtree(tempdir) + + def test_playlist_load_ascii(self): + the_playlist_file = path.join(RSRC, b'playlist.m3u') + m3ufile = M3UFile(the_playlist_file) + m3ufile.load() + self.assertEqual(m3ufile.media_list[0], + '/This/is/a/path/to_a_file.mp3\n') + + def test_playlist_load_unicode(self): + the_playlist_file = path.join(RSRC, b'playlist.m3u8') + m3ufile = M3UFile(the_playlist_file) + m3ufile.load() + self.assertEqual(m3ufile.media_list[0], + '/This/is/å/path/to_a_file.mp3\n') + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') From 68240f6e03d91c622a53d02b90b0755673fd2e7f Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Fri, 12 Aug 2022 09:18:45 +0200 Subject: [PATCH 14/46] convert: playlist: Add EmptyPlaylistError and test - Add and Exception class called EmptyPlaylistError ought to be raised when playlists without files are loaded or saved. - Add a test for it in test_m3ufile - Fix media_files vs. media_list attribute name. --- beets/util/__init__.py | 21 ++++++++++++++++----- test/test_m3ufile.py | 6 +++--- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 58eb47034..9f0460ce1 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -124,6 +124,13 @@ class FilesystemError(HumanReadableException): return f'{self._reasonstr()} {clause}' +class EmptyPlaylistError(Exception): + """An error that should be raised when a playlist file without media files + is saved or loaded. + """ + pass + + class MoveOperation(Enum): """The file operations that e.g. various move functions can carry out. """ @@ -161,25 +168,29 @@ class M3UFile(): # Some EXTM3U comment, do something. FIXME continue self.media_list.append(line) + if not self.media_list: + raise EmptyPlaylistError - def set_contents(self, media_files, extm3u=True): - """Sets self.media_files to a list of media file paths, + def set_contents(self, media_list, extm3u=True): + """Sets self.media_list to a list of media file paths, and sets additional flags, changing the final m3u-file's format. - ``media_files`` is a list of paths to media files that should be added + ``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_files = media_files + self.media_list = media_list self.extm3u = extm3u def write(self): """Writes the m3u file to disk.""" header = ["#EXTM3U"] if self.extm3u else [] - contents = header + self.media_files + if not self.media_list: + raise EmptyPlaylistError + contents = header + self.media_list with open(self.path, "w") as playlist_file: playlist_file.writelines('\n'.join(contents)) playlist_file.write('\n') # Final linefeed to prevent noeol file. diff --git a/test/test_m3ufile.py b/test/test_m3ufile.py index 4f5807b6b..e0ab80fa0 100644 --- a/test/test_m3ufile.py +++ b/test/test_m3ufile.py @@ -20,7 +20,7 @@ import unittest # from unittest.mock import Mock, MagicMock -from beets.util import M3UFile +from beets.util import M3UFile, EmptyPlaylistError from beets.util import syspath, bytestring_path, py3_path, CHAR_REPLACE from test._common import RSRC @@ -30,8 +30,8 @@ class M3UFileTest(unittest.TestCase): tempdir = bytestring_path(mkdtemp()) the_playlist_file = path.join(tempdir, b'playlist.m3u8') m3ufile = M3UFile(the_playlist_file) - m3ufile.write() - self.assertFalse(path.exists(the_playlist_file)) + with self.assertRaises(EmptyPlaylistError): + m3ufile.write() rmtree(tempdir) def test_playlist_write(self): From 39e4b90b5c8c947950cc54beb0f197cc216724bd Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Fri, 12 Aug 2022 09:33:09 +0200 Subject: [PATCH 15/46] convert: playlist: Add tests checking extm3u and fix extm3u check in load method. --- beets/util/__init__.py | 2 +- test/rsrc/playlist_non_ext.m3u | 2 ++ test/test_m3ufile.py | 12 ++++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 test/rsrc/playlist_non_ext.m3u diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 9f0460ce1..e28039927 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -162,7 +162,7 @@ class M3UFile(): """ with open(self.path, "r") as playlist_file: raw_contents = playlist_file.readlines() - self.extm3u = True if raw_contents[0] == "#EXTM3U" else False + self.extm3u = True if raw_contents[0] == "#EXTM3U\n" else False for line in raw_contents[1:]: if line.startswith("#"): # Some EXTM3U comment, do something. FIXME 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/test_m3ufile.py b/test/test_m3ufile.py index e0ab80fa0..a3f8703b5 100644 --- a/test/test_m3ufile.py +++ b/test/test_m3ufile.py @@ -72,6 +72,18 @@ class M3UFileTest(unittest.TestCase): self.assertEqual(m3ufile.media_list[0], '/This/is/å/path/to_a_file.mp3\n') + def test_playlist_load_extm3u(self): + 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): + the_playlist_file = path.join(RSRC, b'playlist_non_ext.m3u') + m3ufile = M3UFile(the_playlist_file) + m3ufile.load() + self.assertFalse(m3ufile.extm3u) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) From 01b77f5602636a45d9f32a2d70a422469eb4725e Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Wed, 29 Mar 2023 07:45:51 +0200 Subject: [PATCH 16/46] convert: playlist: Add changelog entry --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 435c85709..78bbb249e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -68,6 +68,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: From 7d121c390b3fb36717571c72c353c2a5090c8242 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Mon, 22 Aug 2022 10:46:12 +0200 Subject: [PATCH 17/46] convert: playlist: Move M3UFile class to separate module in util package. --- beets/util/__init__.py | 61 --------------------------------- beets/util/m3u.py | 77 ++++++++++++++++++++++++++++++++++++++++++ beetsplug/convert.py | 2 +- 3 files changed, 78 insertions(+), 62 deletions(-) create mode 100644 beets/util/m3u.py diff --git a/beets/util/__init__.py b/beets/util/__init__.py index e28039927..2319890a3 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -124,13 +124,6 @@ class FilesystemError(HumanReadableException): return f'{self._reasonstr()} {clause}' -class EmptyPlaylistError(Exception): - """An error that should be raised when a playlist file without media files - is saved or loaded. - """ - pass - - class MoveOperation(Enum): """The file operations that e.g. various move functions can carry out. """ @@ -142,60 +135,6 @@ class MoveOperation(Enum): REFLINK_AUTO = 5 -class M3UFile(): - def __init__(self, path): - """Reads and writes m3u or m3u8 playlist files. - - ``path`` is the full 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. - """ - with open(self.path, "r") as playlist_file: - raw_contents = playlist_file.readlines() - self.extm3u = True if raw_contents[0] == "#EXTM3U\n" else False - for line in raw_contents[1:]: - if line.startswith("#"): - # Some EXTM3U comment, do something. FIXME - continue - self.media_list.append(line) - 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, - - and 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.""" - header = ["#EXTM3U"] if self.extm3u else [] - if not self.media_list: - raise EmptyPlaylistError - contents = header + self.media_list - with open(self.path, "w") as playlist_file: - playlist_file.writelines('\n'.join(contents)) - playlist_file.write('\n') # Final linefeed to prevent noeol file. - - def normpath(path): """Provide the canonical form of the path suitable for storing in the database. diff --git a/beets/util/m3u.py b/beets/util/m3u.py new file mode 100644 index 000000000..3db3f4084 --- /dev/null +++ b/beets/util/m3u.py @@ -0,0 +1,77 @@ +# 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 an manipulate m3u playlist files. +""" + + +class EmptyPlaylistError(Exception): + """An error that should be raised when a playlist file without media files + is saved or loaded. + """ + pass + + +class M3UFile(): + def __init__(self, path): + """Reads and writes m3u or m3u8 playlist files. + + ``path`` is the full 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. + """ + with open(self.path, "r") as playlist_file: + raw_contents = playlist_file.readlines() + self.extm3u = True if raw_contents[0] == "#EXTM3U\n" else False + for line in raw_contents[1:]: + if line.startswith("#"): + # Some EXTM3U comment, do something. FIXME + continue + self.media_list.append(line) + 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, + + and 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.""" + header = ["#EXTM3U"] if self.extm3u else [] + if not self.media_list: + raise EmptyPlaylistError + contents = header + self.media_list + with open(self.path, "w") as playlist_file: + playlist_file.writelines('\n'.join(contents)) + playlist_file.write('\n') # Final linefeed to prevent noeol file. diff --git a/beetsplug/convert.py b/beetsplug/convert.py index ab6e4c420..416fb9502 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -31,7 +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 import M3UFile +from beets.util.m3u import M3UFile _fs_lock = threading.Lock() _temp_files = [] # Keep track of temporary transcoded files for deletion. From 8dc556d4204df2cb760e950a68dda050eaee179f Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Mon, 22 Aug 2022 11:23:33 +0200 Subject: [PATCH 18/46] convert: playlist: Use syspath() for file read and write operations. --- beets/util/m3u.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/beets/util/m3u.py b/beets/util/m3u.py index 3db3f4084..983833afb 100644 --- a/beets/util/m3u.py +++ b/beets/util/m3u.py @@ -16,6 +16,9 @@ """ +from beets.util import syspath + + class EmptyPlaylistError(Exception): """An error that should be raised when a playlist file without media files is saved or loaded. @@ -41,7 +44,7 @@ class M3UFile(): def load(self): """Reads the m3u file from disk and sets the object's attributes. """ - with open(self.path, "r") as playlist_file: + with open(syspath(self.path), "r") as playlist_file: raw_contents = playlist_file.readlines() self.extm3u = True if raw_contents[0] == "#EXTM3U\n" else False for line in raw_contents[1:]: @@ -72,6 +75,6 @@ class M3UFile(): if not self.media_list: raise EmptyPlaylistError contents = header + self.media_list - with open(self.path, "w") as playlist_file: + with open(syspath(self.path), "w") as playlist_file: playlist_file.writelines('\n'.join(contents)) playlist_file.write('\n') # Final linefeed to prevent noeol file. From cb630c45f6dadb43cbb4beef26434f71676b0b1b Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Mon, 22 Aug 2022 12:22:39 +0200 Subject: [PATCH 19/46] convert: playlist: Also use syspath() for contents of playlist. We want to have processed every entry in the media list we pass to the M3UFile instance. --- beetsplug/convert.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 416fb9502..88c99e53d 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -485,9 +485,9 @@ class ConvertPlugin(BeetsPlugin): # computers. self._log.info("Creating playlist file: {0}", playlist) items_paths = [ - item.destination( + util.syspath(item.destination( basedir=dest, path_formats=path_formats, fragment=True - ) for item in items + )) for item in items ] if not pretend: m3ufile = M3UFile(playlist) From c1908d551af57751ed906226ea299b2d1f4c118b Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Tue, 23 Aug 2022 08:00:18 +0200 Subject: [PATCH 20/46] convert: playlist: Document the feature leaving out the fact that #EXTM3U is added to the playlist file header (that important?). --- docs/plugins/convert.rst | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index 622a8f2cd..8f00058b0 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,21 @@ 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. Either a simple filename or a relative path plus +a filename can be passed. The generated playlist will always use relative paths +to the contained media files to ensure compatibility when read from external +drives or on computers other than the one used for the conversion. Also refer +to the ``playlist`` option below. + +Note that the classic m3u format doesn't support special characters in media +file paths, thus the m3u8 format which requires media file paths to be unicode, +is used. Typically a playlist file would be named *.m3u8. The name of the file +can be freely chosen by the user though. Since it is always ensured that paths +to media files are written as defined by the ``path`` configuration, a +generated playlist potentially could contain unicode characters no matter what +file ending was chosen. Configuration ------------- @@ -124,6 +140,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): From 2c1163cbc5c80e15c6f3813bff64b52a1fa5c393 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Wed, 24 Aug 2022 23:14:04 +0200 Subject: [PATCH 21/46] convert: playlist: Linter and import fixes in m3u module and testsuite. --- beets/util/m3u.py | 19 +++++++------------ test/test_m3ufile.py | 20 ++++++++++++++------ 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/beets/util/m3u.py b/beets/util/m3u.py index 983833afb..94e366531 100644 --- a/beets/util/m3u.py +++ b/beets/util/m3u.py @@ -12,25 +12,21 @@ # 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 an manipulate m3u playlist files. -""" +"""Provides utilities to read, write an manipulate m3u playlist files.""" from beets.util import syspath class EmptyPlaylistError(Exception): - """An error that should be raised when a playlist file without media files - is saved or loaded. - """ + """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): - """Reads and writes m3u or m3u8 playlist files. - - ``path`` is the full path to the playlist file. + """``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 @@ -42,8 +38,7 @@ class M3UFile(): self.media_list = [] def load(self): - """Reads the m3u file from disk and sets the object's attributes. - """ + """Reads the m3u file from disk and sets the object's attributes.""" with open(syspath(self.path), "r") as playlist_file: raw_contents = playlist_file.readlines() self.extm3u = True if raw_contents[0] == "#EXTM3U\n" else False @@ -56,9 +51,9 @@ class M3UFile(): raise EmptyPlaylistError def set_contents(self, media_list, extm3u=True): - """Sets self.media_list to a list of media file paths, + """Sets self.media_list to a list of media file paths. - and sets additional flags, changing the final m3u-file's format. + 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 diff --git a/test/test_m3ufile.py b/test/test_m3ufile.py index a3f8703b5..73dc57d54 100644 --- a/test/test_m3ufile.py +++ b/test/test_m3ufile.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2016, Johannes Tiefenbacher. +# Copyright 2016, J0J0 Todos. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -11,22 +11,23 @@ # # 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, remove +from os import path from tempfile import mkdtemp from shutil import rmtree import unittest -# from unittest.mock import Mock, MagicMock - -from beets.util import M3UFile, EmptyPlaylistError -from beets.util import syspath, bytestring_path, py3_path, CHAR_REPLACE +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) @@ -35,6 +36,7 @@ class M3UFileTest(unittest.TestCase): 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) @@ -47,6 +49,7 @@ class M3UFileTest(unittest.TestCase): 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) @@ -59,6 +62,7 @@ class M3UFileTest(unittest.TestCase): rmtree(tempdir) 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() @@ -66,6 +70,7 @@ class M3UFileTest(unittest.TestCase): '/This/is/a/path/to_a_file.mp3\n') 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() @@ -73,12 +78,14 @@ class M3UFileTest(unittest.TestCase): '/This/is/å/path/to_a_file.mp3\n') 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() @@ -86,6 +93,7 @@ class M3UFileTest(unittest.TestCase): def suite(): + """This testsuite's main function.""" return unittest.TestLoader().loadTestsFromName(__name__) From a1baf9e94b404db5aa8e26604a9f929b9419fd99 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Thu, 25 Aug 2022 08:24:37 +0200 Subject: [PATCH 22/46] convert: playlist: Fix rst linter error in docs Fix "inline empasis start string without endstring" error in docs. --- docs/plugins/convert.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index 8f00058b0..a68745da9 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -65,7 +65,7 @@ to the ``playlist`` option below. Note that the classic m3u format doesn't support special characters in media file paths, thus the m3u8 format which requires media file paths to be unicode, -is used. Typically a playlist file would be named *.m3u8. The name of the file +is used. Typically a playlist file would be named `*.m3u8`. The name of the file can be freely chosen by the user though. Since it is always ensured that paths to media files are written as defined by the ``path`` configuration, a generated playlist potentially could contain unicode characters no matter what From 785ef1576cfd42138080f35e0b906cfab0b258c5 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Sat, 27 Aug 2022 15:22:50 +0200 Subject: [PATCH 23/46] convert: playlist: Use syspath() for media files loading as well. --- beets/util/m3u.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/util/m3u.py b/beets/util/m3u.py index 94e366531..eca958941 100644 --- a/beets/util/m3u.py +++ b/beets/util/m3u.py @@ -46,7 +46,7 @@ class M3UFile(): if line.startswith("#"): # Some EXTM3U comment, do something. FIXME continue - self.media_list.append(line) + self.media_list.append(syspath(line)) if not self.media_list: raise EmptyPlaylistError From da01be3d936638336ef9f43ae06dff8bd20b60f4 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Sat, 27 Aug 2022 19:09:06 +0200 Subject: [PATCH 24/46] convert: playlist: Enforce utf-8 encoding on load() and write(). --- beets/util/m3u.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beets/util/m3u.py b/beets/util/m3u.py index eca958941..d5a55a65f 100644 --- a/beets/util/m3u.py +++ b/beets/util/m3u.py @@ -39,7 +39,7 @@ class M3UFile(): def load(self): """Reads the m3u file from disk and sets the object's attributes.""" - with open(syspath(self.path), "r") as playlist_file: + with open(syspath(self.path), "r", encoding="utf-8") as playlist_file: raw_contents = playlist_file.readlines() self.extm3u = True if raw_contents[0] == "#EXTM3U\n" else False for line in raw_contents[1:]: @@ -70,6 +70,6 @@ class M3UFile(): if not self.media_list: raise EmptyPlaylistError contents = header + self.media_list - with open(syspath(self.path), "w") as playlist_file: + with open(syspath(self.path), "w", encoding="utf-8") as playlist_file: playlist_file.writelines('\n'.join(contents)) playlist_file.write('\n') # Final linefeed to prevent noeol file. From 5f5be52a89de30442efede8fe3a948ba0c84bdc9 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Sat, 27 Aug 2022 19:32:59 +0200 Subject: [PATCH 25/46] convert: playlist: Debug commit: Learn syspath() Learn what's happening in syspath(). --- beets/util/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 2319890a3..70ba7b996 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -409,11 +409,17 @@ def syspath(path, prefix=True): # reported as the FS encoding by Windows. Try both. try: path = path.decode('utf-8') + print("syspath: this is path:") + print(path) except UnicodeError: # The encoding should always be MBCS, Windows' broken # Unicode representation. encoding = sys.getfilesystemencoding() or sys.getdefaultencoding() path = path.decode(encoding, 'replace') + print("syspath: this is encoding:") + print(encoding) + print("syspath: this is path:") + print(path) # Add the magic prefix if it isn't already there. # https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx From bd5335f31fe021d3c04b9f681f6023816d0c1ccc Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Sun, 28 Aug 2022 13:19:33 +0200 Subject: [PATCH 26/46] convert: playlist: Separate unicode test for Windows --- test/rsrc/playlist_windows.m3u8 | 3 +++ test/test_m3ufile.py | 11 +++++++++++ 2 files changed, 14 insertions(+) create mode 100644 test/rsrc/playlist_windows.m3u8 diff --git a/test/rsrc/playlist_windows.m3u8 b/test/rsrc/playlist_windows.m3u8 new file mode 100644 index 000000000..97da8660a --- /dev/null +++ b/test/rsrc/playlist_windows.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/test_m3ufile.py b/test/test_m3ufile.py index 73dc57d54..4e13a9c1d 100644 --- a/test/test_m3ufile.py +++ b/test/test_m3ufile.py @@ -18,6 +18,7 @@ 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 @@ -69,6 +70,7 @@ class M3UFileTest(unittest.TestCase): self.assertEqual(m3ufile.media_list[0], '/This/is/a/path/to_a_file.mp3\n') + @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') @@ -77,6 +79,15 @@ class M3UFileTest(unittest.TestCase): self.assertEqual(m3ufile.media_list[0], '/This/is/å/path/to_a_file.mp3\n') + @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') + m3ufile = M3UFile(the_playlist_file) + m3ufile.load() + self.assertEqual(m3ufile.media_list[0], + '\\\\?\\/This/is/å/path/to_a_file.mp3\n') + def test_playlist_load_extm3u(self): """Test loading a playlist with an #EXTM3U header.""" the_playlist_file = path.join(RSRC, b'playlist.m3u') From b3d0c1cc1cacb79be56504edc3ade4596a8794e5 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Sun, 28 Aug 2022 13:26:06 +0200 Subject: [PATCH 27/46] Revert "convert: playlist: Debug commit: Learn syspath()" This reverts commit 8a7519e5057e9c11a5f95c979b2fd5ac6c1fd9e2. --- beets/util/__init__.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 70ba7b996..2319890a3 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -409,17 +409,11 @@ def syspath(path, prefix=True): # reported as the FS encoding by Windows. Try both. try: path = path.decode('utf-8') - print("syspath: this is path:") - print(path) except UnicodeError: # The encoding should always be MBCS, Windows' broken # Unicode representation. encoding = sys.getfilesystemencoding() or sys.getdefaultencoding() path = path.decode(encoding, 'replace') - print("syspath: this is encoding:") - print(encoding) - print("syspath: this is path:") - print(path) # Add the magic prefix if it isn't already there. # https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx From 004d10a143fcb89291ead9c4293be828b7353d8e Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Sun, 28 Aug 2022 13:31:32 +0200 Subject: [PATCH 28/46] convert: playlist: Put actual Windows paths into fixture file for the Windows unittest. --- test/rsrc/playlist_windows.m3u8 | 4 ++-- test/test_m3ufile.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/rsrc/playlist_windows.m3u8 b/test/rsrc/playlist_windows.m3u8 index 97da8660a..c1f4af63a 100644 --- a/test/rsrc/playlist_windows.m3u8 +++ b/test/rsrc/playlist_windows.m3u8 @@ -1,3 +1,3 @@ #EXTM3U -\\\\?\\/This/is/å/path/to_a_file.mp3 -\\\\?\\/This/is/another/path/tö_a_file.mp3 +x:\This\is\å\path\to_a_file.mp3 +x:\This\is\another\path\tö_a_file.mp3 diff --git a/test/test_m3ufile.py b/test/test_m3ufile.py index 4e13a9c1d..f9fd37dbd 100644 --- a/test/test_m3ufile.py +++ b/test/test_m3ufile.py @@ -86,7 +86,7 @@ class M3UFileTest(unittest.TestCase): m3ufile = M3UFile(the_playlist_file) m3ufile.load() self.assertEqual(m3ufile.media_list[0], - '\\\\?\\/This/is/å/path/to_a_file.mp3\n') + 'x:\This\is\å\path\to_a_file.mp3\n') def test_playlist_load_extm3u(self): """Test loading a playlist with an #EXTM3U header.""" From 31b9e7afeb90978bdec72aeed8d0170fd3eec479 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Sun, 28 Aug 2022 13:44:19 +0200 Subject: [PATCH 29/46] convert: playlist: Construct Windows path programatically --- test/test_m3ufile.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/test_m3ufile.py b/test/test_m3ufile.py index f9fd37dbd..e250e7b3a 100644 --- a/test/test_m3ufile.py +++ b/test/test_m3ufile.py @@ -85,8 +85,10 @@ class M3UFileTest(unittest.TestCase): the_playlist_file = path.join(RSRC, b'playlist_windows.m3u8') m3ufile = M3UFile(the_playlist_file) m3ufile.load() - self.assertEqual(m3ufile.media_list[0], - 'x:\This\is\å\path\to_a_file.mp3\n') + self.assertEqual( + m3ufile.media_list[0], + path.join('x:', 'This', 'is', 'å', 'path', 'to_a_file.mp3') + '\n' + ) def test_playlist_load_extm3u(self): """Test loading a playlist with an #EXTM3U header.""" From e4213714ba6b8e23d16d0289ed4bb6c96cf8b9ed Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Sun, 28 Aug 2022 13:46:47 +0200 Subject: [PATCH 30/46] convert: playlist: Disable prefix in syspath on in load method when loading media files to content list. --- beets/util/m3u.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/util/m3u.py b/beets/util/m3u.py index d5a55a65f..6a6aca4b5 100644 --- a/beets/util/m3u.py +++ b/beets/util/m3u.py @@ -46,7 +46,7 @@ class M3UFile(): if line.startswith("#"): # Some EXTM3U comment, do something. FIXME continue - self.media_list.append(syspath(line)) + self.media_list.append(syspath(line, prefix=False)) if not self.media_list: raise EmptyPlaylistError From 54d22bea6e0a5170cd8e8e6f016a7029a53fc0dd Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Sun, 28 Aug 2022 14:21:58 +0200 Subject: [PATCH 31/46] convert: playlist: Construct winpath before assert --- test/test_m3ufile.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/test_m3ufile.py b/test/test_m3ufile.py index e250e7b3a..a8cb09f66 100644 --- a/test/test_m3ufile.py +++ b/test/test_m3ufile.py @@ -83,11 +83,12 @@ class M3UFileTest(unittest.TestCase): 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 = path.join('x:', 'This', 'is', 'å', 'path', 'to_a_file.mp3') m3ufile = M3UFile(the_playlist_file) m3ufile.load() self.assertEqual( m3ufile.media_list[0], - path.join('x:', 'This', 'is', 'å', 'path', 'to_a_file.mp3') + '\n' + winpath + '\n' ) def test_playlist_load_extm3u(self): From a641fd151e632d592712e79d3addd95bc87b5517 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Sun, 28 Aug 2022 19:12:54 +0200 Subject: [PATCH 32/46] convert: playlist: debug winpath in test --- test/test_m3ufile.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/test_m3ufile.py b/test/test_m3ufile.py index a8cb09f66..976c40508 100644 --- a/test/test_m3ufile.py +++ b/test/test_m3ufile.py @@ -84,6 +84,8 @@ class M3UFileTest(unittest.TestCase): """Test loading unicode paths from a playlist file.""" the_playlist_file = path.join(RSRC, b'playlist_windows.m3u8') winpath = path.join('x:', 'This', 'is', 'å', 'path', 'to_a_file.mp3') + print("this is winpath:") + print(winpath) m3ufile = M3UFile(the_playlist_file) m3ufile.load() self.assertEqual( From 39efd23d06a6fa8868b242e305e9ecdbebc4fd08 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Sun, 28 Aug 2022 19:37:10 +0200 Subject: [PATCH 33/46] convert: playlist: Fix winpath driveletter in test Needs to be put including (double) backslash! --- test/test_m3ufile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_m3ufile.py b/test/test_m3ufile.py index 976c40508..c5b7f49ec 100644 --- a/test/test_m3ufile.py +++ b/test/test_m3ufile.py @@ -83,7 +83,7 @@ class M3UFileTest(unittest.TestCase): 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 = path.join('x:', 'This', 'is', 'å', 'path', 'to_a_file.mp3') + winpath = path.join('x:\\', 'This', 'is', 'å', 'path', 'to_a_file.mp3') print("this is winpath:") print(winpath) m3ufile = M3UFile(the_playlist_file) From ff03ecaa2774b5d8010749c88f2894181e0121c2 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Sun, 28 Aug 2022 20:05:16 +0200 Subject: [PATCH 34/46] convert: playlist: Add another Windows test Add test_playlist_write_and_read_unicode_windows: Writes 2 media file paths containing unicode characters, reads them in using M3UFile class again and tests if the contents is correct. --- test/test_m3ufile.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/test_m3ufile.py b/test/test_m3ufile.py index c5b7f49ec..a59b8fb76 100644 --- a/test/test_m3ufile.py +++ b/test/test_m3ufile.py @@ -62,6 +62,32 @@ class M3UFileTest(unittest.TestCase): 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([ + r"x:\This\is\å\path\to_a_file.mp3", + 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], + path.join('x:\\', 'This', 'is', 'å', 'path', 'to_a_file.mp3') + ) + self.assertEquals( + m3ufile.media_list[1], + r"x:\This\is\another\path\tö_a_file.mp3", + path.join('x:\\', 'This', 'is', 'another', 'path', 'tö_a_file.mp3') + ) + rmtree(tempdir) + def test_playlist_load_ascii(self): """Test loading ascii paths from a playlist file.""" the_playlist_file = path.join(RSRC, b'playlist.m3u') From c28eb95ef2d4b3e2704f15a715cac128ddd061f6 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Sun, 28 Aug 2022 20:20:51 +0200 Subject: [PATCH 35/46] convert: playlist: Remove debug print winpath in test. --- test/test_m3ufile.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/test_m3ufile.py b/test/test_m3ufile.py index a59b8fb76..fedfa90a6 100644 --- a/test/test_m3ufile.py +++ b/test/test_m3ufile.py @@ -110,8 +110,6 @@ class M3UFileTest(unittest.TestCase): """Test loading unicode paths from a playlist file.""" the_playlist_file = path.join(RSRC, b'playlist_windows.m3u8') winpath = path.join('x:\\', 'This', 'is', 'å', 'path', 'to_a_file.mp3') - print("this is winpath:") - print(winpath) m3ufile = M3UFile(the_playlist_file) m3ufile.load() self.assertEqual( From d248063f96f296dfc24cc48af318fbc515e6a9d5 Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Mon, 29 Aug 2022 14:15:03 +0200 Subject: [PATCH 36/46] convert: playlist: Improve --playlist help text --- beetsplug/convert.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 88c99e53d..9493a5fc2 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -180,14 +180,14 @@ class ConvertPlugin(BeetsPlugin): help='hardlink files that do not \ need transcoding. Overrides --link.') cmd.parser.add_option('-m', '--playlist', action='store', - help='''the name of an m3u8 playlist file to - be created in the root of the destination folder. - The m3u8 format ensures special characters - support by using unicode to save media file - paths. Relative paths are used to point to media - files ensuring a working playlist when - transferred to a different computer (eg. when - opened from an external drive).''') + 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] From 068208f71e8f1058b7f9ab7afb47dbd9c4a9cc0b Mon Sep 17 00:00:00 2001 From: J0J0 T Date: Wed, 21 Sep 2022 08:31:42 +0200 Subject: [PATCH 37/46] convert: Fix copyright year in test_m3ufile.py --- test/test_m3ufile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_m3ufile.py b/test/test_m3ufile.py index fedfa90a6..07635597b 100644 --- a/test/test_m3ufile.py +++ b/test/test_m3ufile.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2016, J0J0 Todos. +# 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 From 20a0012f796cf2a07ce4983a19379a7b03abb53b Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sat, 4 Mar 2023 20:38:56 +0100 Subject: [PATCH 38/46] convert: playlist: Use normpath for playlist file Fixes FileNotFoundError when for example a tilde (~) characteris used for a --dest path. --- beets/util/m3u.py | 4 ++-- beetsplug/convert.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/beets/util/m3u.py b/beets/util/m3u.py index 6a6aca4b5..75468c0fa 100644 --- a/beets/util/m3u.py +++ b/beets/util/m3u.py @@ -15,7 +15,7 @@ """Provides utilities to read, write an manipulate m3u playlist files.""" -from beets.util import syspath +from beets.util import syspath, normpath class EmptyPlaylistError(Exception): @@ -70,6 +70,6 @@ class M3UFile(): if not self.media_list: raise EmptyPlaylistError contents = header + self.media_list - with open(syspath(self.path), "w", encoding="utf-8") as playlist_file: + with open(syspath(normpath(self.path)), "w", encoding="utf-8") as playlist_file: playlist_file.writelines('\n'.join(contents)) playlist_file.write('\n') # Final linefeed to prevent noeol file. diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 9493a5fc2..b77b9dfa2 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -483,7 +483,8 @@ class ConvertPlugin(BeetsPlugin): # media file paths to be unicode. Additionally we use relative # paths to ensure readability of the playlist on remote # computers. - self._log.info("Creating playlist file: {0}", playlist) + self._log.info("Creating playlist file: {0}", + util.normpath(playlist)) items_paths = [ util.syspath(item.destination( basedir=dest, path_formats=path_formats, fragment=True From 46fb8fef914c84a9e1279a09ff131fefd05f0e82 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sat, 4 Mar 2023 20:45:27 +0100 Subject: [PATCH 39/46] convert: playlist: Fix typo in m3u module docstring --- beets/util/m3u.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/util/m3u.py b/beets/util/m3u.py index 75468c0fa..7903157a5 100644 --- a/beets/util/m3u.py +++ b/beets/util/m3u.py @@ -12,7 +12,7 @@ # 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 an manipulate m3u playlist files.""" +"""Provides utilities to read, write and manipulate m3u playlist files.""" from beets.util import syspath, normpath From 952aa0baddb92dcacce782f64423d9e27cd16d7b Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 5 Mar 2023 12:39:41 +0100 Subject: [PATCH 40/46] convert: playlist: Handle playlist path subdirs The M3UFile.write() method now creates potential parent directories in a passed playlist path. util.mkdirall() handles errors nicely already and would exit the mainprogram before potential subsequent failures could happen (it raises util.FilesystemError). --- beets/util/m3u.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/beets/util/m3u.py b/beets/util/m3u.py index 7903157a5..e026ccf03 100644 --- a/beets/util/m3u.py +++ b/beets/util/m3u.py @@ -15,7 +15,7 @@ """Provides utilities to read, write and manipulate m3u playlist files.""" -from beets.util import syspath, normpath +from beets.util import syspath, normpath, mkdirall class EmptyPlaylistError(Exception): @@ -65,11 +65,17 @@ class M3UFile(): self.extm3u = extm3u def write(self): - """Writes the m3u file to disk.""" + """Writes the m3u file to disk. + + Handles the creation of potential parent directories. + """ header = ["#EXTM3U"] if self.extm3u else [] if not self.media_list: raise EmptyPlaylistError contents = header + self.media_list - with open(syspath(normpath(self.path)), "w", encoding="utf-8") as playlist_file: + pl_normpath = normpath(self.path) + mkdirall(pl_normpath) + + with open(syspath(pl_normpath), "w", encoding="utf-8") as playlist_file: playlist_file.writelines('\n'.join(contents)) playlist_file.write('\n') # Final linefeed to prevent noeol file. From 0884e67d3544cbef150ea84fa45b77a6bb657bb1 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 5 Mar 2023 13:31:26 +0100 Subject: [PATCH 41/46] convert: playlist: Handle errors on read/write operations of a playlist in M3UFile class, by catching any "OSError" and raising util.FilesystemError. --- beets/util/m3u.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/beets/util/m3u.py b/beets/util/m3u.py index e026ccf03..0d36e5aa1 100644 --- a/beets/util/m3u.py +++ b/beets/util/m3u.py @@ -14,8 +14,9 @@ """Provides utilities to read, write and manipulate m3u playlist files.""" +import traceback -from beets.util import syspath, normpath, mkdirall +from beets.util import syspath, normpath, mkdirall, FilesystemError class EmptyPlaylistError(Exception): @@ -39,8 +40,14 @@ class M3UFile(): def load(self): """Reads the m3u file from disk and sets the object's attributes.""" - with open(syspath(self.path), "r", encoding="utf-8") as playlist_file: - raw_contents = playlist_file.readlines() + pl_normpath = normpath(self.path) + try: + with open(syspath(pl_normpath), "r", encoding="utf-8") 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] == "#EXTM3U\n" else False for line in raw_contents[1:]: if line.startswith("#"): @@ -76,6 +83,10 @@ class M3UFile(): pl_normpath = normpath(self.path) mkdirall(pl_normpath) - with open(syspath(pl_normpath), "w", encoding="utf-8") as playlist_file: - playlist_file.writelines('\n'.join(contents)) - playlist_file.write('\n') # Final linefeed to prevent noeol file. + try: + with open(syspath(pl_normpath), "w", encoding="utf-8") as pl_file: + pl_file.writelines('\n'.join(contents)) + pl_file.write('\n') # Final linefeed to prevent noeol file. + except OSError as exc: + raise FilesystemError(exc, 'create', (pl_normpath, ), + traceback.format_exc()) From a4d03ef5867901a03dfb1c1accf9f2f35fd7b2da Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Wed, 22 Mar 2023 19:55:09 +0100 Subject: [PATCH 42/46] convert: playlist: M3U write + contents as bytes Make sure we stay with the beets standard of handling everything internally as bytes. - M3UFile.write() method writes in wb mode. - Playlist contents and EXTM3U header is handled as bytes. - item.destination() gives us unicode string paths, we tranlate to bytes using util.bytestring_path(). - Fix test_playlist*write* tests to encode UTF-8 assert strings as bytes using bytestring_path() before comparision. --- beets/util/m3u.py | 9 +++++---- beetsplug/convert.py | 2 +- test/test_m3ufile.py | 20 +++++++++++--------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/beets/util/m3u.py b/beets/util/m3u.py index 0d36e5aa1..0f03d78da 100644 --- a/beets/util/m3u.py +++ b/beets/util/m3u.py @@ -76,7 +76,7 @@ class M3UFile(): Handles the creation of potential parent directories. """ - header = ["#EXTM3U"] if self.extm3u else [] + header = [b"#EXTM3U"] if self.extm3u else [] if not self.media_list: raise EmptyPlaylistError contents = header + self.media_list @@ -84,9 +84,10 @@ class M3UFile(): mkdirall(pl_normpath) try: - with open(syspath(pl_normpath), "w", encoding="utf-8") as pl_file: - pl_file.writelines('\n'.join(contents)) - pl_file.write('\n') # Final linefeed to prevent noeol file. + 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 b77b9dfa2..ee9cf3641 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -486,7 +486,7 @@ class ConvertPlugin(BeetsPlugin): self._log.info("Creating playlist file: {0}", util.normpath(playlist)) items_paths = [ - util.syspath(item.destination( + util.bytestring_path(item.destination( basedir=dest, path_formats=path_formats, fragment=True )) for item in items ] diff --git a/test/test_m3ufile.py b/test/test_m3ufile.py index 07635597b..b133f65e1 100644 --- a/test/test_m3ufile.py +++ b/test/test_m3ufile.py @@ -42,8 +42,8 @@ class M3UFileTest(unittest.TestCase): the_playlist_file = path.join(tempdir, b'playlist.m3u') m3ufile = M3UFile(the_playlist_file) m3ufile.set_contents([ - '/This/is/a/path/to_a_file.mp3', - '/This/is/another/path/to_a_file.mp3', + 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)) @@ -55,8 +55,8 @@ class M3UFileTest(unittest.TestCase): the_playlist_file = path.join(tempdir, b'playlist.m3u8') m3ufile = M3UFile(the_playlist_file) m3ufile.set_contents([ - '/This/is/å/path/to_a_file.mp3', - '/This/is/another/path/tö_a_file.mp3', + 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)) @@ -70,8 +70,8 @@ class M3UFileTest(unittest.TestCase): b'playlist_write_and_read_windows.m3u8') m3ufile = M3UFile(the_playlist_file) m3ufile.set_contents([ - r"x:\This\is\å\path\to_a_file.mp3", - r"x:\This\is\another\path\tö_a_file.mp3" + 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)) @@ -79,12 +79,14 @@ class M3UFileTest(unittest.TestCase): m3ufile_read.load() self.assertEquals( m3ufile.media_list[0], - path.join('x:\\', 'This', 'is', 'å', 'path', 'to_a_file.mp3') + bytestring_path( + path.join('x:\\', 'This', 'is', 'å', 'path', 'to_a_file.mp3')) ) self.assertEquals( m3ufile.media_list[1], - r"x:\This\is\another\path\tö_a_file.mp3", - path.join('x:\\', 'This', 'is', 'another', 'path', 'tö_a_file.mp3') + 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) From 99231160a76bca161b14abbd10a32716436fcfbe Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Fri, 24 Mar 2023 08:38:59 +0100 Subject: [PATCH 43/46] convert: playlist: M3U read as bytes - M3UFile.read() method reads in rb mode. - M3UFile.read() method handles removal of (platform specific) line endings. - Playlist contents and EXTM3U header is handled as bytes. - Fix test_playlist*read* tests to encode playlist UTF-8 assert strings to bytes using bytestring_path() before comparision. - Fixture playlist_windows.m3u8 is now actually Windows formatted (\r\n + BOM) --- beets/util/m3u.py | 10 +++++----- test/rsrc/playlist_windows.m3u8 | 6 +++--- test/test_m3ufile.py | 10 ++++++---- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/beets/util/m3u.py b/beets/util/m3u.py index 0f03d78da..9c961a291 100644 --- a/beets/util/m3u.py +++ b/beets/util/m3u.py @@ -42,18 +42,18 @@ class M3UFile(): """Reads the m3u file from disk and sets the object's attributes.""" pl_normpath = normpath(self.path) try: - with open(syspath(pl_normpath), "r", encoding="utf-8") as pl_file: + 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] == "#EXTM3U\n" else False + self.extm3u = True if raw_contents[0].rstrip() == b"#EXTM3U" else False for line in raw_contents[1:]: - if line.startswith("#"): - # Some EXTM3U comment, do something. FIXME + if line.startswith(b"#"): + # Support for specific EXTM3U comments could be added here. continue - self.media_list.append(syspath(line, prefix=False)) + self.media_list.append(normpath(line.rstrip())) if not self.media_list: raise EmptyPlaylistError diff --git a/test/rsrc/playlist_windows.m3u8 b/test/rsrc/playlist_windows.m3u8 index c1f4af63a..f75dfd74f 100644 --- a/test/rsrc/playlist_windows.m3u8 +++ b/test/rsrc/playlist_windows.m3u8 @@ -1,3 +1,3 @@ -#EXTM3U -x:\This\is\å\path\to_a_file.mp3 -x:\This\is\another\path\tö_a_file.mp3 +#EXTM3U +x:\This\is\å\path\to_a_file.mp3 +x:\This\is\another\path\tö_a_file.mp3 diff --git a/test/test_m3ufile.py b/test/test_m3ufile.py index b133f65e1..a24dc6ca8 100644 --- a/test/test_m3ufile.py +++ b/test/test_m3ufile.py @@ -90,13 +90,14 @@ class M3UFileTest(unittest.TestCase): ) 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], - '/This/is/a/path/to_a_file.mp3\n') + bytestring_path('/This/is/a/path/to_a_file.mp3')) @unittest.skipIf(sys.platform == 'win32', 'win32') def test_playlist_load_unicode(self): @@ -105,18 +106,19 @@ class M3UFileTest(unittest.TestCase): m3ufile = M3UFile(the_playlist_file) m3ufile.load() self.assertEqual(m3ufile.media_list[0], - '/This/is/å/path/to_a_file.mp3\n') + 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 = path.join('x:\\', 'This', 'is', 'å', 'path', 'to_a_file.mp3') + 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 + '\n' + winpath ) def test_playlist_load_extm3u(self): From 16e361baf3f9f68dd396c604c427830c93086efb Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 2 Apr 2023 13:08:06 +0200 Subject: [PATCH 44/46] convert: playlist: item_paths relative to playlist Ensure entries in items_paths are generated with a path relative to the location of the playlist file. --- beetsplug/convert.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index ee9cf3641..374b68e74 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -483,12 +483,13 @@ class ConvertPlugin(BeetsPlugin): # media file paths to be unicode. Additionally we use relative # paths to ensure readability of the playlist on remote # computers. - self._log.info("Creating playlist file: {0}", - util.normpath(playlist)) + pl_normpath = util.normpath(playlist) + pl_dir = os.path.dirname(pl_normpath) + self._log.info("Creating playlist file {0}", pl_normpath) items_paths = [ - util.bytestring_path(item.destination( - basedir=dest, path_formats=path_formats, fragment=True - )) for item in items + 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) From 86929eb6a01407e48bf511a0bc63d32508f10b59 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 2 Apr 2023 13:22:09 +0200 Subject: [PATCH 45/46] convert: playlist: Adapt code comments - Remove initial comment around playlist entry condition (which is better suited for user docs anyway, and stated there already) - Add explanation above the items_paths playlist contents creation list comprehension. --- beetsplug/convert.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 374b68e74..ef6865597 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -476,16 +476,13 @@ class ConvertPlugin(BeetsPlugin): link, hardlink, threads, items) if playlist: - # When playlist arg is passed create an m3u8 file in dest folder. - # - # The classic m3u format doesn't support special characters in - # media file paths, thus we use the m3u8 format which requires - # media file paths to be unicode. Additionally we use relative - # paths to ensure readability of the playlist on remote - # computers. + # 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 From 94784c2c2939d85837349a8a27c2c5b146cb1a65 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sun, 2 Apr 2023 21:14:46 +0200 Subject: [PATCH 46/46] convert: playlist: Documentation overhaul --- docs/plugins/convert.rst | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index a68745da9..d70d354bf 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -57,19 +57,16 @@ 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. Either a simple filename or a relative path plus -a filename can be passed. The generated playlist will always use relative paths -to the contained media files to ensure compatibility when read from external -drives or on computers other than the one used for the conversion. Also refer -to the ``playlist`` option below. +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. -Note that the classic m3u format doesn't support special characters in media -file paths, thus the m3u8 format which requires media file paths to be unicode, -is used. Typically a playlist file would be named `*.m3u8`. The name of the file -can be freely chosen by the user though. Since it is always ensured that paths -to media files are written as defined by the ``path`` configuration, a -generated playlist potentially could contain unicode characters no matter what -file ending was chosen. +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 -------------