diff --git a/beets/mediafile.py b/beets/mediafile.py index b2a72d84c..64ab49ac2 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -1460,6 +1460,34 @@ class MediaFile(object): if isinstance(descriptor, MediaField): yield property.decode('utf8') + @classmethod + def _field_sort_name(cls, name): + """Get a sort key for a field name that determines the order + fields should be written in. + + Fields names are kept unchanged, unless they are instances of + :class:`DateItemField`, in which case `year`, `month`, and `day` + are replaced by `date0`, `date1`, and `date2`, respectively, to + make them appear in that order. + """ + if isinstance(cls.__dict__[name], DateItemField): + name = re.sub('year', 'date0', name) + name = re.sub('month', 'date1', name) + name = re.sub('day', 'date2', name) + return name + + @classmethod + def sorted_fields(cls): + """Get the names of all writable metadata fields, sorted in the + order that they should be written. + + This is a lexicographic order, except for instances of + :class:`DateItemField`, which are sorted in year-month-day + order. + """ + for property in sorted(cls.fields(), key=cls._field_sort_name): + yield property + @classmethod def readable_fields(cls): """Get all metadata fields: the writable ones from @@ -1496,7 +1524,7 @@ class MediaFile(object): the `MediaFile`. If a key has the value `None`, the corresponding property is deleted from the `MediaFile`. """ - for field in self.fields(): + for field in self.sorted_fields(): if field in dict: if dict[field] is None: delattr(self, field) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 2a37a113d..f3386cf71 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -1485,7 +1485,7 @@ def config_edit(): try: if not os.path.isfile(path): open(path, 'w+').close() - util.interactive_open(path, editor) + util.interactive_open([path], editor) except OSError as exc: message = "Could not edit configuration: {0}".format(exc) if not editor: diff --git a/beets/util/__init__.py b/beets/util/__init__.py index ed1320807..a32c44908 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -735,8 +735,8 @@ def open_anything(): return base_cmd -def interactive_open(target, command=None): - """Open the file `target` by `exec`ing a new command. (The new +def interactive_open(targets, command=None): + """Open the files in `targets` by `exec`ing a new command. (The new program takes over, and Python execution ends: this does not fork a subprocess.) @@ -757,7 +757,8 @@ def interactive_open(target, command=None): base_cmd = open_anything() command = [base_cmd, base_cmd] - command.append(target) + command += targets + return os.execlp(*command) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index aef7c06ae..b807891e0 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -38,7 +38,7 @@ except ImportError: HAVE_ITUNES = False IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg'] -CONTENT_TYPES = ('image/jpeg', 'image/gif') +CONTENT_TYPES = ('image/jpeg', 'image/png') DOWNLOAD_EXTENSION = '.jpg' diff --git a/beetsplug/play.py b/beetsplug/play.py index e6611ad3f..038bfd42d 100644 --- a/beetsplug/play.py +++ b/beetsplug/play.py @@ -39,6 +39,7 @@ class PlayPlugin(BeetsPlugin): 'command': None, 'use_folders': False, 'relative_to': None, + 'raw': False, }) def commands(self): @@ -62,6 +63,7 @@ class PlayPlugin(BeetsPlugin): command_str = config['play']['command'].get() use_folders = config['play']['use_folders'].get(bool) relative_to = config['play']['relative_to'].get() + raw = config['play']['raw'].get(bool) if relative_to: relative_to = util.normpath(relative_to) @@ -91,6 +93,8 @@ class PlayPlugin(BeetsPlugin): else: selection = lib.items(ui.decargs(args)) paths = [item.path for item in selection] + if relative_to: + paths = [relpath(path, relative_to) for path in paths] item_type = 'track' item_type += 's' if len(selection) > 1 else '' @@ -111,22 +115,29 @@ class PlayPlugin(BeetsPlugin): if ui.input_options(('Continue', 'Abort')) == 'a': return - # Create temporary m3u file to hold our playlist. - m3u = NamedTemporaryFile('w', suffix='.m3u', delete=False) - for item in paths: - if relative_to: - m3u.write(relpath(item, relative_to) + b'\n') - else: - m3u.write(item + b'\n') - m3u.close() - ui.print_(u'Playing {0} {1}.'.format(len(selection), item_type)) + if raw: + open_args = paths + else: + open_args = self._create_tmp_playlist(paths) - self._log.debug('executing command: {} {}', command_str, m3u.name) + self._log.debug('executing command: {} {}', command_str, + b'"' + b' '.join(open_args) + b'"') try: - util.interactive_open(m3u.name, command_str) + util.interactive_open(open_args, command_str) except OSError as exc: raise ui.UserError("Could not play the music playlist: " "{0}".format(exc)) finally: - util.remove(m3u.name) + if not raw: + self._log.debug('Removing temporary playlist: {}', + open_args[0]) + util.remove(open_args[0]) + + def _create_tmp_playlist(self, paths_list): + # Create temporary m3u file to hold our playlist. + m3u = NamedTemporaryFile('w', suffix='.m3u', delete=False) + for item in paths_list: + m3u.write(item + b'\n') + m3u.close() + return [m3u.name] diff --git a/docs/changelog.rst b/docs/changelog.rst index a9e92709a..f173e7c0a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,6 +12,9 @@ The new features: the player command. :bug:`1532` * A new :doc:`/plugins/badfiles` helps you scan for corruption in your music collection. Thanks to :user:`fxthomas`. :bug:`1568` +* :doc:`/plugins/play`: A new ``raw`` configuration option lets the command + work with players (such as VLC) that expect music filenames as arguments, + rather than in a playlist. Thanks to :user:`nathdwek`. :bug:`1578` Fixes: @@ -27,6 +30,12 @@ Fixes: option. * The :ref:`list-cmd` command's help output now has a small query and format string example. Thanks to :user:`pkess`. :bug:`1582` +* :doc:`/plugins/fetchart`: The plugin now fetches PNGs but not GIFs. (It + still fetches JPEGs.) This avoids an error when trying to embed images, + since not all formats support GIFs. :bug:`1588` +* Date fields are now written in the correct order (year-month-day), which + eliminates an intermittent bug where the latter two fields would not get + written to files. Thanks to :user:`jdetrey`. :bug:`1303` :bug:`1589` * The check whether the file system is case sensitive or not could lead to wrong results. It is much more robust now. diff --git a/docs/plugins/play.rst b/docs/plugins/play.rst index 08172c7c9..9af886c13 100644 --- a/docs/plugins/play.rst +++ b/docs/plugins/play.rst @@ -44,6 +44,9 @@ configuration file. The available options are: paths to each track on the matched albums. Enable this option to store paths to folders instead. Default: ``no``. +- **raw**: Instead of creating a temporary m3u playlist and then opening it, + simply call the command with the paths returned by the query as arguments. + Default: ``no``. Optional Arguments ------------------ diff --git a/test/test_util.py b/test/test_util.py index 464c7e678..324a4d589 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -43,11 +43,11 @@ class UtilTest(unittest.TestCase): @patch('beets.util.open_anything') def test_interactive_open(self, mock_open, mock_execlp): mock_open.return_value = 'tagada' - util.interactive_open('foo') + util.interactive_open(['foo']) mock_execlp.assert_called_once_with('tagada', 'tagada', 'foo') mock_execlp.reset_mock() - util.interactive_open('foo', 'bar') + util.interactive_open(['foo'], 'bar') mock_execlp.assert_called_once_with('bar', 'bar', 'foo') def test_sanitize_unix_replaces_leading_dot(self):