Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

convert: New feature "Write m3u playlist to destination folder" #4399

Merged
merged 46 commits into from
Apr 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
861bc69
convert: Add a quick & dirty m3u playlist feature
JOJ0 Jun 18, 2022
d448e0c
convert: Refine and fix playlist feature
JOJ0 Jul 4, 2022
fd8fe69
convert: Playlist feature linting fixes
JOJ0 Jul 4, 2022
16e25bb
convert: playlist feature: Fix relative paths
JOJ0 Jul 4, 2022
c0b1bc9
convert: playlist feature: Better relative path gen
JOJ0 Jul 6, 2022
d589e77
convert: playlist: Fix redundant join path
JOJ0 Jul 10, 2022
c251ed1
convert: playlist: Generate m3u file in one batch
JOJ0 Jul 10, 2022
5dfff50
convert: playlist: Refactor m3u writing to class
JOJ0 Jul 13, 2022
9930a5d
convert: playlist: Test playlist existence
JOJ0 Aug 10, 2022
e41525a
convert: playlist: Remove clutter from test
JOJ0 Aug 12, 2022
55b3863
convert: playlist: Move m3u creation after conversions
JOJ0 Aug 12, 2022
ba3740c
convert: playlist: Fix filename attr in load method
JOJ0 Aug 12, 2022
0cbf91e
convert: playlist: Add test_m3ufile and fixtures
JOJ0 Aug 12, 2022
68240f6
convert: playlist: Add EmptyPlaylistError and test
JOJ0 Aug 12, 2022
39e4b90
convert: playlist: Add tests checking extm3u and
JOJ0 Aug 12, 2022
01b77f5
convert: playlist: Add changelog entry
JOJ0 Mar 29, 2023
7d121c3
convert: playlist: Move M3UFile class to separate
JOJ0 Aug 22, 2022
8dc556d
convert: playlist: Use syspath()
JOJ0 Aug 22, 2022
cb630c4
convert: playlist: Also use syspath() for contents
JOJ0 Aug 22, 2022
c1908d5
convert: playlist: Document the feature
JOJ0 Aug 23, 2022
2c1163c
convert: playlist: Linter and import fixes
JOJ0 Aug 24, 2022
a1baf9e
convert: playlist: Fix rst linter error in docs
JOJ0 Aug 25, 2022
785ef15
convert: playlist: Use syspath() for media files
JOJ0 Aug 27, 2022
da01be3
convert: playlist: Enforce utf-8 encoding on load()
JOJ0 Aug 27, 2022
5f5be52
convert: playlist: Debug commit: Learn syspath()
JOJ0 Aug 27, 2022
bd5335f
convert: playlist: Separate unicode test for Windows
JOJ0 Aug 28, 2022
b3d0c1c
Revert "convert: playlist: Debug commit: Learn syspath()"
JOJ0 Aug 28, 2022
004d10a
convert: playlist: Put actual Windows paths
JOJ0 Aug 28, 2022
31b9e7a
convert: playlist: Construct Windows path programatically
JOJ0 Aug 28, 2022
e421371
convert: playlist: Disable prefix in syspath on
JOJ0 Aug 28, 2022
54d22be
convert: playlist: Construct winpath before assert
JOJ0 Aug 28, 2022
a641fd1
convert: playlist: debug winpath in test
JOJ0 Aug 28, 2022
39efd23
convert: playlist: Fix winpath driveletter in test
JOJ0 Aug 28, 2022
ff03eca
convert: playlist: Add another Windows test
JOJ0 Aug 28, 2022
c28eb95
convert: playlist: Remove debug print winpath
JOJ0 Aug 28, 2022
d248063
convert: playlist: Improve --playlist help text
JOJ0 Aug 29, 2022
068208f
convert: Fix copyright year in test_m3ufile.py
JOJ0 Sep 21, 2022
20a0012
convert: playlist: Use normpath for playlist file
JOJ0 Mar 4, 2023
46fb8fe
convert: playlist: Fix typo in m3u module docstring
JOJ0 Mar 4, 2023
952aa0b
convert: playlist: Handle playlist path subdirs
JOJ0 Mar 5, 2023
0884e67
convert: playlist: Handle errors on read/write
JOJ0 Mar 5, 2023
a4d03ef
convert: playlist: M3U write + contents as bytes
JOJ0 Mar 22, 2023
9923116
convert: playlist: M3U read as bytes
JOJ0 Mar 24, 2023
16e361b
convert: playlist: item_paths relative to playlist
JOJ0 Apr 2, 2023
86929eb
convert: playlist: Adapt code comments
JOJ0 Apr 2, 2023
94784c2
convert: playlist: Documentation overhaul
JOJ0 Apr 2, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions beets/util/m3u.py
Original file line number Diff line number Diff line change
@@ -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())
42 changes: 38 additions & 4 deletions beetsplug/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
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.
Expand Down Expand Up @@ -149,6 +150,7 @@ def __init__(self):
'copy_album_art': False,
'album_art_maxwidth': 0,
'delete_originals': False,
'playlist': None,
})
self.early_import_stages = [self.auto_convert, self.auto_convert_keep]

Expand Down Expand Up @@ -177,6 +179,15 @@ def commands(self):
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]
Expand Down Expand Up @@ -436,7 +447,7 @@ def copy_album_art(self, album, dest_dir, path_formats, pretend=False,

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))
Expand All @@ -461,8 +472,26 @@ def convert_func(self, lib, opts, args):
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()
Copy link
Member Author

@JOJ0 JOJ0 Mar 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wisp3rwind may I ask for your advice on a specific thing here: I recently came across about a PR where you cleaned up a little and moved the wrapping through syspath to the very last thing happening before a filesystem operation is done by beets: https://github.com/beetbox/beets/pull/4675/files#diff-1ab0ce830f9dd337e8c57f809d3488b4dfb78c12a61a2a526100038e05a7ca54R29

I think back when I wrote this feature / opened this PR I did not have a good understanding of what and why syspath() wrapping is required. Have a look (best via "Files changed" tab) at line 488 above where I'm generating items_paths

In words: I'm generating a list of paths to media files that then will be saved into an m3u playlist file. I wrap every one of these paths through syspath()

I now think that this actually is not the purpose of syspath() since I do not actually do a filesystem operation from within beets. I should not use syspath() here. Is that correct?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As an aside, I'm preparing to go through the codebase and convert everything to pathlib which will negate the need for syspath calls.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah pathlib! That is a cool package! I use it all the time in my projects! Great idea!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds right: Currently, beets' code represents every path internally as a bytes object. To keep things manageable, paths should be converted to this representation immediately upon ingestion from any source (CLI, input files, library APIs, ...), and converted to other representations as late as possible (usually just upon leaving beets' direct control, i.e. when writing them to a file or the DB, etc).

To be honest, I'm also not entirely sure without studying the code again what exactly is the right approach when receiving paths from, say, the commandline. Anyway, yes, syspath seems like the wrong function here, at least without further research on whether it does the right thing on each platform. The question to answer here would be how m3u files should normally be encoded in each case, and ensure that beets adheres to that.

As a matter of fact, does your code actually run on Linux? At a first glance, I would expect that M3UFile.write() would crash when trying to join the paths (bytes as returned by syspath) with newlines.

Sorry for the late reply, I missed that you mentioned me here!

Copy link
Member Author

@JOJ0 JOJ0 Mar 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @wisp3rwind thanks a lot for the reply, late or not, it's helpful! :-)

As a matter of fact, does your code actually run on Linux? At a first glance, I would expect that M3UFile.write() would crash when trying to join the paths (bytes as returned by syspath) with newlines.

Code is tested on macOS Catalina and Manjaro Linux (my former dev machine and my current main dev machine ;-))

One thing before I answer more: Is it possible that you are confusing the syspath function with normpath or even bytestring_path (which is called by normpath)

Reason being: syspath doesn't do anything on Linux/Posix, it just returns as-is. As I understood syspath, it actually is ment for sanitizing Windows paths:

def syspath(path, prefix=True):
"""Convert a path for use by the operating system. In particular,
paths on Windows must receive a magic prefix and must be converted
to Unicode before they are sent to the OS. To disable the magic
prefix on Windows, set `prefix` to False---but only do this if you
*really* know what you're doing.
"""
# Don't do anything if we're not on windows
if os.path.__name__ != 'ntpath':
return path
if not isinstance(path, str):
# Beets currently represents Windows paths internally with UTF-8
# arbitrarily. But earlier versions used MBCS because it is
# reported as the FS encoding by Windows. Try both.
try:
path = path.decode('utf-8')
except UnicodeError:
# The encoding should always be MBCS, Windows' broken
# Unicode representation.
encoding = sys.getfilesystemencoding() or sys.getdefaultencoding()
path = path.decode(encoding, 'replace')
# Add the magic prefix if it isn't already there.
# https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx
if prefix and not path.startswith(WINDOWS_MAGIC_PREFIX):
if path.startswith('\\\\'):
# UNC path. Final path should look like \\?\UNC\...
path = 'UNC' + path[1:]
path = WINDOWS_MAGIC_PREFIX + path
return path

def normpath(path):
"""Provide the canonical form of the path suitable for storing in
the database.
"""
path = syspath(path, prefix=False)
path = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
return bytestring_path(path)

Copy link
Member Author

@JOJ0 JOJ0 Mar 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The question to answer here would be how m3u files should normally be encoded in each case, and ensure that beets adheres to that.

Exactly! I did my research back then when I started to code this around last summer (See description and todos here: #4399 (comment)). It turns out that there is no real standard of what m3u is. It is rather loose, therefore tools do what the dev's thought it should do. The best we can do is do a compromise to work for the most circumstances. The main points from my research are:

  • The best summary / documentation seems to be this wikipedia page: https://en.wikipedia.org/wiki/M3U
    • Interesting points:
      • m3u contains platform specific paths
      • m3u8 requires paths to be unicode! -> This is what I'm aiming for, this is how this feature is documented so far. So this is my actual review question: Am I aiming at this goal?
  • Even proprietory DJ Tools (which are one of my target tools as a DJ obviously) might not always do a 100% the right thing, thus this feature needs some flexibility. (One tiny example: Native Instruments Traktor understands m3u8 formatted playlists (utf-8 paths) but it does not understand the file ending of m3u8)
    We find a little about this here: https://en.wikipedia.org/wiki/M3U#M3U8
  • Some tools (like VLC media player) url-encode all the paths because they think (the dev's) that it's the only way to make sure paths have one standardized format. That is indeed a nice idea BUT those playlists don't work almost anywhere else. URL-encoding is definitely not what I'm aiming for, it would miss my goal that playlists should actually be usable in other tools, besides VLC ;-) I spare you the reading of those vlc forum posts where users are complaining that vlc-generated playlists don't work anywhere else anymore and why the hell dev's don't provide an option to revert to a more compatible m3u format....)
  • One more thing about VLC: It can read regular paths in m3u/m3u8 files. However, if user hits "save playlist", they will be saved url-encoded, thus renders them unreadable for other tools.

So to summarize what my goal is and what I think makes the most sense for usability (for this feature):

  • UTF-8 encoded contents/paths to media files.
  • Contents/media file paths are relative paths always
  • The beets user can choose whether they want to name them .m3u or .m3u8 or even something else
  • Playlists exported on a Linux machine should work on Linux
  • Playlists exported on a Mac should work on a Mac
  • Playlists exported on Windows should work on Windows
  • (Most-probably Mac and Linux would even be interchangeable (Posix))

Copy link
Member Author

@JOJ0 JOJ0 Mar 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a matter of fact, does your code actually run on Linux? At a first glance, I would expect that M3UFile.write() would crash when trying to join the paths (bytes as returned by syspath) with newlines.

Code is tested on macOS Catalina and Manjaro Linux (my former dev machine and my current main dev machine ;-))

One thing before I answer more: Is it possible that you are confusing the syspath function with normpath or even bytestring_path (which is called by normpath)

Reason being: syspath doesn't do anything on Linux/Posix, it just returns as-is. As I understood syspath, it actually is ment for sanitizing Windows paths:

Ah, sorry, now I understand: Since internally we are having bytes paths,. syspath, even when not doing anything on Non-Windows systems, will return exactly that: The internal representation of paths, we still have bytes

So to make my question more concrete now @wisp3rwind : My goal is to make sure that all paths that go in the m3u list are converted to UTF-8. Which function from utils could I use?

displayable_path()? Sounds odd but actually it translates bytes to unicode.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

def displayable_path(path, separator='; '):
"""Attempts to decode a bytestring path to a unicode object for the
purpose of displaying it to the user. If the `path` argument is a
list or a tuple, the elements are joined with `separator`.
"""
if isinstance(path, (list, tuple)):
return separator.join(displayable_path(p) for p in path)
elif isinstance(path, str):
return path
elif not isinstance(path, bytes):
# A non-string object: just get its unicode representation.
return str(path)
try:
return path.decode(_fsencoding(), 'ignore')
except (UnicodeError, LookupError):
return path.decode('utf-8', 'ignore')


def convert_on_import(self, lib, item):
"""Transcode a file automatically after it is imported into the
Expand Down Expand Up @@ -544,6 +573,10 @@ def _get_opts_and_config(self, opts):

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:
Expand All @@ -559,7 +592,8 @@ def _get_opts_and_config(self, opts):
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):
Expand Down
3 changes: 3 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
21 changes: 20 additions & 1 deletion docs/plugins/convert.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
-------------
Expand Down Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions test/rsrc/playlist.m3u
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#EXTM3U
/This/is/a/path/to_a_file.mp3
/This/is/another/path/to_a_file.mp3
3 changes: 3 additions & 0 deletions test/rsrc/playlist.m3u8
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#EXTM3U
/This/is/å/path/to_a_file.mp3
/This/is/another/path/tö_a_file.mp3
2 changes: 2 additions & 0 deletions test/rsrc/playlist_non_ext.m3u
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/This/is/a/path/to_a_file.mp3
/This/is/another/path/to_a_file.mp3
3 changes: 3 additions & 0 deletions test/rsrc/playlist_windows.m3u8
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#EXTM3U
x:\This\is\å\path\to_a_file.mp3
x:\This\is\another\path\tö_a_file.mp3
11 changes: 11 additions & 0 deletions test/test_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,17 @@ def test_transcode_when_maxbr_set_to_none_and_same_formats(self):
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,
Expand Down
Loading