diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 45f13efef..b7e6b1e2b 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -39,6 +39,7 @@ replace: '\.$': _ '\s+$': '' '^\s+': '' + '^-': _ path_sep_replace: _ asciify_paths: false art_filename: cover diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 6b0ed8b43..ef7231a76 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -33,6 +33,15 @@ from .query import MatchQuery, NullSort, TrueQuery import six +class DBAccessError(Exception): + """The SQLite database became inaccessible. + + This can happen when trying to read or write the database when, for + example, the database file is deleted or otherwise disappears. There + is probably no way to recover from this error. + """ + + class FormattedMapping(collections.Mapping): """A `dict`-like formatted view of a model. @@ -680,8 +689,18 @@ class Transaction(object): """Execute an SQL statement with substitution values and return the row ID of the last affected row. """ - cursor = self.db._connection().execute(statement, subvals) - return cursor.lastrowid + try: + cursor = self.db._connection().execute(statement, subvals) + return cursor.lastrowid + except sqlite3.OperationalError as e: + # In two specific cases, SQLite reports an error while accessing + # the underlying database file. We surface these exceptions as + # DBAccessError so the application can abort. + if e.args[0] in ("attempt to write a readonly database", + "unable to open database file"): + raise DBAccessError(e.args[0]) + else: + raise def script(self, statements): """Execute a string containing multiple SQL statements.""" diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 29e228497..60652fa08 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -41,6 +41,7 @@ from beets import config from beets.util import confit, as_string from beets.autotag import mb from beets.dbcore import query as db_query +from beets.dbcore import db import six # On Windows platforms, use colorama to support "ANSI" terminal colors. @@ -1122,9 +1123,11 @@ def _configure(options): # special handling lets specified plugins get loaded before we # finish parsing the command line. if getattr(options, 'config', None) is not None: - config_path = options.config + overlay_path = options.config del options.config - config.set_file(config_path) + config.set_file(overlay_path) + else: + overlay_path = None config.set_args(options) # Configure the logger. @@ -1133,6 +1136,10 @@ def _configure(options): else: log.set_global_level(logging.INFO) + if overlay_path: + log.debug(u'overlaying configuration: {0}', + util.displayable_path(overlay_path)) + config_path = config.user_config_path() if os.path.isfile(config_path): log.debug(u'user configuration: {0}', @@ -1247,3 +1254,10 @@ def main(args=None): except KeyboardInterrupt: # Silently ignore ^C except in verbose mode. log.debug(u'{}', traceback.format_exc()) + except db.DBAccessError as exc: + log.error( + u'database access error: {0}\n' + u'the library file might have a permissions problem', + exc + ) + sys.exit(1) diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 8a73efa16..fc412d998 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -394,10 +394,10 @@ class BeatportPlugin(BeetsPlugin): # cause a query to return no results, even if they match the artist or # album title. Use `re.UNICODE` flag to avoid stripping non-english # word characters. - query = re.sub(r'\W+', ' ', query, re.UNICODE) + query = re.sub(r'\W+', ' ', query, flags=re.UNICODE) # Strip medium information from query, Things like "CD1" and "disk 1" # can also negate an otherwise positive result. - query = re.sub(r'\b(CD|disc)\s*\d+', '', query, re.I) + query = re.sub(r'\b(CD|disc)\s*\d+', '', query, flags=re.I) albums = [self._get_album_info(x) for x in self.client.search(query)] return albums diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 8af9a62a1..ec4d7c62e 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -24,6 +24,7 @@ import tempfile import shlex import six from string import Template +import platform from beets import ui, util, plugins, config from beets.plugins import BeetsPlugin @@ -183,12 +184,22 @@ class ConvertPlugin(BeetsPlugin): if not quiet and not pretend: self._log.info(u'Encoding {0}', util.displayable_path(source)) - # Substitute $source and $dest in the argument list. + # On Python 3, we need to construct the command to invoke as a + # Unicode string. On Unix, this is a little unfortunate---the OS is + # expecting bytes---so we use surrogate escaping and decode with the + # argument encoding, which is the same encoding that will then be + # *reversed* to recover the same bytes before invoking the OS. On + # Windows, we want to preserve the Unicode filename "as is." if not six.PY2: command = command.decode(util.arg_encoding(), 'surrogateescape') - source = source.decode(util.arg_encoding(), 'surrogateescape') - dest = dest.decode(util.arg_encoding(), 'surrogateescape') + if platform.system() == 'Windows': + source = source.decode(util._fsencoding()) + dest = dest.decode(util._fsencoding()) + else: + source = source.decode(util.arg_encoding(), 'surrogateescape') + dest = dest.decode(util.arg_encoding(), 'surrogateescape') + # Substitute $source and $dest in the argument list. args = shlex.split(command) encode_cmd = [] for i, arg in enumerate(args): diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index db450d9c8..1060a2dd8 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -49,29 +49,28 @@ def find_feat_part(artist, albumartist): """Attempt to find featured artists in the item's artist fields and return the results. Returns None if no featured artist found. """ - feat_part = None - # Look for the album artist in the artist field. If it's not # present, give up. albumartist_split = artist.split(albumartist, 1) if len(albumartist_split) <= 1: - return feat_part + return None # If the last element of the split (the right-hand side of the # album artist) is nonempty, then it probably contains the # featured artist. - elif albumartist_split[-1] != '': + elif albumartist_split[1] != '': # Extract the featured artist from the right-hand side. - _, feat_part = split_on_feat(albumartist_split[-1]) + _, feat_part = split_on_feat(albumartist_split[1]) + return feat_part # Otherwise, if there's nothing on the right-hand side, look for a # featuring artist on the left-hand side. else: lhs, rhs = split_on_feat(albumartist_split[0]) if lhs: - feat_part = lhs + return lhs - return feat_part + return None class FtInTitlePlugin(plugins.BeetsPlugin): diff --git a/beetsplug/play.py b/beetsplug/play.py index 9e912dbce..8477acbfc 100644 --- a/beetsplug/play.py +++ b/beetsplug/play.py @@ -81,6 +81,11 @@ class PlayPlugin(BeetsPlugin): action='store', help=u'add additional arguments to the command', ) + play_command.parser.add_option( + u'-y', u'--yes', + action="store_true", + help=u'skip the warning threshold', + ) play_command.func = self._play_command return [play_command] @@ -125,8 +130,8 @@ class PlayPlugin(BeetsPlugin): # Check if the selection exceeds configured threshold. If True, # cancel, otherwise proceed with play command. - if not self._exceeds_threshold(selection, command_str, open_args, - item_type): + if opts.yes or not self._exceeds_threshold( + selection, command_str, open_args, item_type): play(command_str, selection, paths, open_args, self._log, item_type) diff --git a/docs/changelog.rst b/docs/changelog.rst index ddb6ec603..bae5c1e49 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -49,9 +49,14 @@ New features: :bug:`2366` :bug:`2495` * Importing a release with multiple release events now selects the event based on your :ref:`preferred` countries. :bug:`2501` +* :doc:`/plugins/play`: A new ``-y`` or ``--yes`` parameter lets you skip + the warning message if you enqueue more items than the warning threshold + usually allows. Fixes: +* In the :ref:`replace` configuration option, we now replace a leading hyphen + (-) with an underscore. :bug:`549` :bug:`2509` * :doc:`/plugins/absubmit`: Do not filter for supported formats. :bug:`2471` * :doc:`/plugins/mpdupdate`: Fix Python 3 compatibility. :bug:`2381` * :doc:`/plugins/replaygain`: Fix Python 3 compatibility in the ``bs1770gain`` @@ -86,6 +91,10 @@ Fixes: AAC codec instead of faac. Thanks to :user:`jansol`. :bug:`2484` * Fix import of multidisc releases with subdirectories, which previously made each disc be imported separately in different releases. :bug:`2493` +* Invalid date queries now print an error message instead of being silently + ignored. Thanks to :user:`discopatrick`. :bug:`2513` :bug:`2517` +* When the SQLite database stops being accessible, we now print a friendly + error message. Thanks to :user:`Mary011196`. :bug:`1676` :bug:`2508` 1.4.3 (January 9, 2017) @@ -770,6 +779,8 @@ Fixes: does not exist, beets creates an empty file before editing it. This fixes an error on OS X, where the ``open`` command does not work with non-existent files. :bug:`1480` +* :doc:`/plugins/convert`: Fix a problem with filename encoding on Windows + under Python 3. :bug:`2515` :bug:`2516` .. _Python bug: http://bugs.python.org/issue16512 .. _ipfs: http://ipfs.io diff --git a/docs/plugins/play.rst b/docs/plugins/play.rst index 9b9110bde..3a08a4239 100644 --- a/docs/plugins/play.rst +++ b/docs/plugins/play.rst @@ -95,6 +95,10 @@ example:: indicates that you need to insert extra arguments before specifying the playlist. +The ``--yes`` (or ``-y``) flag to the ``play`` command will skip the warning +message if you choose to play more items than the **warning_threshold** +value usually allows. + Note on the Leakage of the Generated Playlists ---------------------------------------------- diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 403c1e174..b4c7b9e1b 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -72,7 +72,7 @@ box. To extract `rar` files, install the `rarfile`_ package and the Optional command flags: * By default, the command copies files your the library directory and - updates the ID3 tags on your music. In order to move the files, instead of + updates the ID3 tags on your music. In order to move the files, instead of copying, use the ``-m`` (move) option. If you'd like to leave your music files untouched, try the ``-C`` (don't copy) and ``-W`` (don't write tags) options. You can also disable this behavior by default in the @@ -409,7 +409,11 @@ import ...``. * ``-v``: verbose mode; prints out a deluge of debugging information. Please use this flag when reporting bugs. You can use it twice, as in ``-vv``, to make beets even more verbose. -* ``-c FILE``: read a specified YAML :doc:`configuration file `. +* ``-c FILE``: read a specified YAML :doc:`configuration file `. This + configuration works as an overlay: rather than replacing your normal + configuration options entirely, the two are merged. Any individual options set + in this config file will override the corresponding settings in your base + configuration. Beets also uses the ``BEETSDIR`` environment variable to look for configuration and data. diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 7fb7c96c4..82c11238a 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -131,6 +131,7 @@ unexpected behavior on all popular platforms:: '\.$': _ '\s+$': '' '^\s+': '' + '^-': _ These substitutions remove forward and back slashes, leading dots, and control characters—all of which is a good idea on any OS. The fourth line diff --git a/test/rsrc/convert_stub.py b/test/rsrc/convert_stub.py index f32bce09a..cb42692d7 100755 --- a/test/rsrc/convert_stub.py +++ b/test/rsrc/convert_stub.py @@ -28,12 +28,9 @@ def convert(in_file, out_file, tag): if not isinstance(tag, bytes): tag = tag.encode('utf-8') - # On Windows, use Unicode paths. (The test harness gives them to us - # as UTF-8 bytes.) - if platform.system() == 'Windows': - if not PY2: - in_file = in_file.encode(arg_encoding()) - out_file = out_file.encode(arg_encoding()) + # On Windows, use Unicode paths. On Python 3, we get the actual, + # Unicode filenames. On Python 2, we get them as UTF-8 byes. + if platform.system() == 'Windows' and PY2: in_file = in_file.decode('utf-8') out_file = out_file.decode('utf-8') diff --git a/test/test_convert.py b/test/test_convert.py index 2a32e51e7..aa0cd0a34 100644 --- a/test/test_convert.py +++ b/test/test_convert.py @@ -15,6 +15,7 @@ from __future__ import division, absolute_import, print_function +import sys import re import os.path import unittest @@ -27,6 +28,15 @@ from beets.mediafile import MediaFile from beets import util +def shell_quote(text): + if sys.version_info[0] < 3: + import pipes + return pipes.quote(text) + else: + import shlex + return shlex.quote(text) + + class TestHelper(helper.TestHelper): def tagged_copy_cmd(self, tag): @@ -39,7 +49,8 @@ class TestHelper(helper.TestHelper): # A Python script that copies the file and appends a tag. stub = os.path.join(_common.RSRC, b'convert_stub.py').decode('utf-8') - return u"python '{}' $source $dest {}".format(stub, tag) + return u"{} {} $source $dest {}".format(shell_quote(sys.executable), + shell_quote(stub), tag) def assertFileTag(self, path, tag): # noqa """Assert that the path is a file and the files content ends with `tag`. @@ -273,5 +284,6 @@ class NeverConvertLossyFilesTest(unittest.TestCase, TestHelper, def suite(): return unittest.TestLoader().loadTestsFromName(__name__) + if __name__ == '__main__': unittest.main(defaultTest='suite') diff --git a/test/test_play.py b/test/test_play.py index 86fef99a9..9721143cc 100644 --- a/test/test_play.py +++ b/test/test_play.py @@ -115,6 +115,20 @@ class PlayPluginTest(unittest.TestCase, TestHelper): open_mock.assert_not_called() + def test_skip_warning_threshold_bypass(self, open_mock): + self.config['play']['warning_threshold'] = 1 + self.other_item = self.add_item(title='another NiceTitle') + + expected_playlist = u'{0}\n{1}'.format( + self.item.path.decode('utf-8'), + self.other_item.path.decode('utf-8')) + + with control_stdin("a"): + self.run_and_assert( + open_mock, + [u'-y', u'NiceTitle'], + expected_playlist=expected_playlist) + def test_command_failed(self, open_mock): open_mock.side_effect = OSError(u"some reason")