From eae98aff0e6af64810e67302baeb808ab6fe2b48 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Tue, 17 Feb 2015 12:19:21 +0100 Subject: [PATCH 01/28] PathQuery is case-{,in}sensitive on {UNIX,Windows} PathQuery use LIKE on Windows and instr() = 1 on UNIX. Fix #1165. --- beets/library.py | 27 +++++++++++++++++++++------ test/test_query.py | 13 +++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/beets/library.py b/beets/library.py index 8d95561f7..35b763321 100644 --- a/beets/library.py +++ b/beets/library.py @@ -24,6 +24,7 @@ import unicodedata import time import re from unidecode import unidecode +import platform from beets import logging from beets.mediafile import MediaFile, MutagenError, UnreadableFileError @@ -42,30 +43,44 @@ log = logging.getLogger('beets') # Library-specific query types. class PathQuery(dbcore.FieldQuery): - """A query that matches all items under a given path.""" + """A query that matches all items under a given path. + + On Windows paths are case-insensitive, contratly to UNIX platforms. + """ escape_re = re.compile(r'[\\_%]') escape_char = b'\\' + _is_windows = platform.system() == 'Windows' + def __init__(self, field, pattern, fast=True): super(PathQuery, self).__init__(field, pattern, fast) + if self._is_windows: + pattern = pattern.lower() + # Match the path as a single file. self.file_path = util.bytestring_path(util.normpath(pattern)) # As a directory (prefix). self.dir_path = util.bytestring_path(os.path.join(self.file_path, b'')) def match(self, item): - return (item.path == self.file_path) or \ - item.path.startswith(self.dir_path) + path = item.path.lower() if self._is_windows else item.path + return (path == self.file_path) or path.startswith(self.dir_path) def col_clause(self): + file_blob = buffer(self.file_path) + + if not self._is_windows: + dir_blob = buffer(self.dir_path) + return '({0} = ?) || (instr({0}, ?) = 1)'.format(self.field), \ + (file_blob, dir_blob) + escape = lambda m: self.escape_char + m.group(0) dir_pattern = self.escape_re.sub(escape, self.dir_path) - dir_pattern = buffer(dir_pattern + b'%') - file_blob = buffer(self.file_path) + dir_blob = buffer(dir_pattern + b'%') return '({0} = ?) || ({0} LIKE ? ESCAPE ?)'.format(self.field), \ - (file_blob, dir_pattern, self.escape_char) + (file_blob, dir_blob, self.escape_char) # Library-specific field types. diff --git a/test/test_query.py b/test/test_query.py index a9b1058bd..c6aec6185 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -17,6 +17,8 @@ from __future__ import (division, absolute_import, print_function, unicode_literals) +from mock import patch + from test import _common from test._common import unittest from test import helper @@ -461,6 +463,17 @@ class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin): results = self.lib.albums(q) self.assert_albums_matched(results, ['album with backslash']) + def test_case_sensitivity(self): + self.add_album(path='/A/B/C2.mp3', title='caps path') + q = b'path:/A/B' + with patch('beets.library.PathQuery._is_windows', False): + results = self.lib.items(q) + self.assert_items_matched(results, ['caps path']) + + with patch('beets.library.PathQuery._is_windows', True): + results = self.lib.items(q) + self.assert_items_matched(results, ['path item', 'caps path']) + class IntQueryTest(unittest.TestCase, TestHelper): From 83e34322e911ab3e3a74751442edea36ab31ef7b Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Tue, 17 Feb 2015 13:13:30 +0100 Subject: [PATCH 02/28] Update changelog & docs --- docs/changelog.rst | 1 + docs/reference/query.rst | 2 ++ 2 files changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 329770102..6b9c57781 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -65,6 +65,7 @@ Core changes: Fixes: +* Path queries are case-sensitive on UNIX OSes. :bug:`1165` * :doc:`/plugins/lyrics`: Silence a warning about insecure requests in the new MusixMatch backend. :bug:`1204` * Fix a crash when ``beet`` is invoked without arguments. :bug:`1205` diff --git a/docs/reference/query.rst b/docs/reference/query.rst index 7dc79461a..af676a50d 100644 --- a/docs/reference/query.rst +++ b/docs/reference/query.rst @@ -184,6 +184,8 @@ Note that this only matches items that are *already in your library*, so a path query won't necessarily find *all* the audio files in a directory---just the ones you've already added to your beets library. +Such queries are case-sensitive on UNIX and case-insensitive on Microsoft +Windows. .. _query-sort: From 6fc678e9470e1469ba93b5011867ee29d406df82 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 18 Feb 2015 18:52:22 +0100 Subject: [PATCH 03/28] PathQuery: use substr() instead of instr() substr() is only available in SQLite 3.7.15+, which is not available yet on Debian stable, CentOS & co. Use substr() instead. --- beets/library.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beets/library.py b/beets/library.py index 35b763321..c5503dd0a 100644 --- a/beets/library.py +++ b/beets/library.py @@ -73,8 +73,8 @@ class PathQuery(dbcore.FieldQuery): if not self._is_windows: dir_blob = buffer(self.dir_path) - return '({0} = ?) || (instr({0}, ?) = 1)'.format(self.field), \ - (file_blob, dir_blob) + return '({0} = ?) || (substr({0}, 1, ?) = ?)'.format(self.field), \ + (file_blob, len(dir_blob), dir_blob) escape = lambda m: self.escape_char + m.group(0) dir_pattern = self.escape_re.sub(escape, self.dir_path) From e00d7b7ddcecd3696d93e0d22b3b05c0f1733ee4 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 18 Feb 2015 19:28:03 +0100 Subject: [PATCH 04/28] PathQuery: simple utf8 comparison Test usqge of SQL's substr() with a UTF8 example. The ideal would be to test with non-UTF8 code points, however it is impossible to perform such a query: queries can only be unicode or utf8. --- test/test_query.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/test_query.py b/test/test_query.py index c6aec6185..e53efc29f 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -474,6 +474,12 @@ class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin): results = self.lib.items(q) self.assert_items_matched(results, ['path item', 'caps path']) + def test_utf8_bytes(self): + self.add_album(path=b'/\xc3\xa0/b/c.mp3', title='latin byte') + q = b'path:/\xc3\xa0/b/c.mp3' + results = self.lib.items(q) + self.assert_items_matched(results, ['latin byte']) + class IntQueryTest(unittest.TestCase, TestHelper): From 9e5e7a28e5574b45b92e5a282abbd796cbb18b28 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 18 Feb 2015 19:31:07 +0100 Subject: [PATCH 05/28] InvalidQueryError: resist to any query Even though queries may not contain non-utf8 code points InvalidQueryError ought to be prudent, for such an invalid query would raise an InvalidQueryError which therefore has to be able to manipulate the invalid query. --- beets/dbcore/query.py | 8 +++++++- test/test_library.py | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index cd891148e..e80010ccf 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -37,7 +37,13 @@ class InvalidQueryError(ParsingError): def __init__(self, query, explanation): if isinstance(query, list): query = " ".join(query) - message = "'{0}': {1}".format(query, explanation) + try: + message = "'{0}': {1}".format(query, explanation) + except UnicodeDecodeError: + # queries are unicode. however if for an unholy reason it's not + # the case, an InvalidQueryError may be raised -- and report it + # correctly than fail again here + message = "{0!r}: {1}".format(query, explanation) super(InvalidQueryError, self).__init__(message) diff --git a/test/test_library.py b/test/test_library.py index 6bb88076e..d2193b25f 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -1195,6 +1195,13 @@ class ParseQueryTest(unittest.TestCase): self.assertIsInstance(raised.exception, beets.dbcore.query.ParsingError) + def test_parse_byte_string(self): + with self.assertRaises(beets.dbcore.InvalidQueryError) as raised: + beets.library.parse_query_string(b'f\xf2o', None) + self.assertIn("can't decode", unicode(raised.exception)) + self.assertIsInstance(raised.exception, + beets.dbcore.query.ParsingError) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) From 65a88e2bf40a57010e89918ff8e9e96d44b423f4 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Tue, 24 Feb 2015 19:24:16 +0100 Subject: [PATCH 06/28] Fix StrubPlugin.write_item() expected arguments Fix #1338. --- beetsplug/scrub.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/scrub.py b/beetsplug/scrub.py index f6a3bed27..ac0017474 100644 --- a/beetsplug/scrub.py +++ b/beetsplug/scrub.py @@ -139,7 +139,7 @@ class ScrubPlugin(BeetsPlugin): self._log.error(u'could not scrub {0}: {1}', util.displayable_path(path), exc) - def write_item(self, path): + def write_item(self, item, path, tags): """Automatically embed art into imported albums.""" if not scrubbing and self.config['auto']: self._log.debug(u'auto-scrubbing {0}', util.displayable_path(path)) From ccbe9079718160a89a60a70fe6153028bba7b176 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 24 Feb 2015 22:18:05 -0800 Subject: [PATCH 07/28] Add (skipped) test for #496 --- test/test_library.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/test_library.py b/test/test_library.py index 1a2812b61..e968141a3 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -452,6 +452,22 @@ class DestinationTest(_common.TestCase): self.assertEqual(self.i.destination(), np('base/one/_.mp3')) + @unittest.skip('unimplemented: #496') + def test_truncation_does_not_conflict_with_replacement(self): + # Use a replacement that should always replace the last X in any + # path component with a Z. + self.lib.replacements = [ + (re.compile(r'X$'), u'Z'), + ] + + # Construct an item whose untruncated path ends with a Y but whose + # truncated version ends with an X. + self.i.title = 'X' * 300 + 'Y' + + # The final path should reflect the replacement. + dest = self.i.destination() + self.assertTrue('XZ' in dest) + class ItemFormattedMappingTest(_common.LibTestCase): def test_formatted_item_value(self): From 1385ce11cab8f7fc5c98ad313f0515ea1f660bfb Mon Sep 17 00:00:00 2001 From: jean-marie winters Date: Sat, 28 Feb 2015 15:35:48 +0100 Subject: [PATCH 08/28] Added support for bs1770gain, a loudness-scanner --- beetsplug/replaygain.py | 111 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 2 deletions(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index ce41cad57..655152f8e 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -21,6 +21,7 @@ import collections import itertools import sys import warnings +import re from beets import logging from beets import ui @@ -83,6 +84,109 @@ class Backend(object): raise NotImplementedError() +# BS1770GAIN CLI tool backend. +class bs1770gainBackend(Backend): + def __init__(self, config, log): + super(bs1770gainBackend, self).__init__(config, log) + self.command = 'bs1770gain' + self.method = config["method"].get(unicode) + if self.command: + # Check whether the program is in $PATH. + for cmd in ('bs1770gain'): + try: + call([cmd]) + self.command = cmd + except OSError: + pass + if not self.command: + raise FatalReplayGainError( + 'no bs1770gain command found: install bs1770gain' + ) + def compute_track_gain(self, items): + """Computes the track gain of the given tracks, returns a list + of TrackGain objects. + """ + #supported_items = filter(self.format_supported, items) + output = self.compute_gain(items, False) + return output + + def compute_album_gain(self, album): + """Computes the album gain of the given album, returns an + AlbumGain object. + """ + # TODO: What should be done when not all tracks in the album are + # supported? + + supported_items = album.items() + output = self.compute_gain(supported_items, True) + + return AlbumGain(output[-1], output[:-1]) + + def format_supported(self, item): + """Checks whether the given item is supported by the selected tool. + """ + if 'mp3gain' in self.command and item.format != 'MP3': + return False + elif 'aacgain' in self.command and item.format not in ('MP3', 'AAC'): + return False + return True + + def compute_gain(self, items, is_album): + """Computes the track or album gain of a list of items, returns + a list of TrackGain objects. + When computing album gain, the last TrackGain object returned is + the album gain + """ + + if len(items) == 0: + return [] + + """Compute ReplayGain values and return a list of results + dictionaries as given by `parse_tool_output`. + """ + # Construct shell command. + cmd = [self.command] + cmd = cmd + [self.method] + cmd = cmd + ['-it'] + cmd = cmd + [syspath(i.path) for i in items] + + self._log.debug(u'analyzing {0} files', len(items)) + self._log.debug(u"executing {0}", " ".join(map(displayable_path, cmd))) + output = call(cmd) + self._log.debug(u'analysis finished') + results = self.parse_tool_output(output, + len(items) + (1 if is_album else 0)) + return results + + def parse_tool_output(self, text, num_lines): + """Given the output from bs1770gain, parse the text and + return a list of dictionaries + containing information about each analyzed file. + """ + out = [] + data = unicode(text,errors='ignore') + results=re.findall(r'(\s{2,2}\[\d+\/\d+\].*?|\[ALBUM\].*?)(?=\s{2,2}\[\d+\/\d+\]|\s{2,2}\[ALBUM\]:|done\.$)',data,re.S|re.M) + + for ll in results[0:num_lines]: + parts = ll.split(b'\n') + if len(parts) == 0: + self._log.debug(u'bad tool output: {0}', text) + raise ReplayGainError('bs1770gain failed') + + d = { + 'file': parts[0], + 'gain': float((parts[1].split('/'))[1].split('LU')[0]), + 'peak': float(parts[2].split('/')[1]), + } + + self._log.info('analysed {}gain={};peak={}', + d['file'].rstrip(), d['gain'], d['peak']) + out.append(Gain(d['gain'], d['peak'])) + return out + + +# GStreamer-based backend. + # mpgain/aacgain CLI tool backend. @@ -179,6 +283,7 @@ class CommandBackend(Backend): else: # Disable clipping warning. cmd = cmd + ['-c'] + cmd = cmd + ['-a' if is_album else '-r'] cmd = cmd + ['-d', bytes(self.gain_offset)] cmd = cmd + [syspath(i.path) for i in items] @@ -598,9 +703,10 @@ class ReplayGainPlugin(BeetsPlugin): """ backends = { - "command": CommandBackend, + "command": CommandBackend, "gstreamer": GStreamerBackend, - "audiotools": AudioToolsBackend + "audiotools": AudioToolsBackend, + "bs1770gain": bs1770gainBackend } def __init__(self): @@ -688,6 +794,7 @@ class ReplayGainPlugin(BeetsPlugin): ) self.store_album_gain(album, album_gain.album_gain) + for item, track_gain in itertools.izip(album.items(), album_gain.track_gains): self.store_track_gain(item, track_gain) From 952081e5edf6f87fec85537c7d14e4e02ae02149 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Sun, 1 Mar 2015 14:52:31 +0100 Subject: [PATCH 09/28] Revert "InvalidQueryError: resist to any query" This reverts commit 9e5e7a28e5574b45b92e5a282abbd796cbb18b28. --- beets/dbcore/query.py | 8 +------- test/test_library.py | 7 ------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index e80010ccf..cd891148e 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -37,13 +37,7 @@ class InvalidQueryError(ParsingError): def __init__(self, query, explanation): if isinstance(query, list): query = " ".join(query) - try: - message = "'{0}': {1}".format(query, explanation) - except UnicodeDecodeError: - # queries are unicode. however if for an unholy reason it's not - # the case, an InvalidQueryError may be raised -- and report it - # correctly than fail again here - message = "{0!r}: {1}".format(query, explanation) + message = "'{0}': {1}".format(query, explanation) super(InvalidQueryError, self).__init__(message) diff --git a/test/test_library.py b/test/test_library.py index d2193b25f..6bb88076e 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -1195,13 +1195,6 @@ class ParseQueryTest(unittest.TestCase): self.assertIsInstance(raised.exception, beets.dbcore.query.ParsingError) - def test_parse_byte_string(self): - with self.assertRaises(beets.dbcore.InvalidQueryError) as raised: - beets.library.parse_query_string(b'f\xf2o', None) - self.assertIn("can't decode", unicode(raised.exception)) - self.assertIsInstance(raised.exception, - beets.dbcore.query.ParsingError) - def suite(): return unittest.TestLoader().loadTestsFromName(__name__) From cb504ad163ec81348c2b30c5d169a7cb9f448495 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Sun, 1 Mar 2015 14:57:10 +0100 Subject: [PATCH 10/28] library.parse_query_string: assert query is unicode --- beets/library.py | 5 +++-- test/test_library.py | 4 ++++ test/test_query.py | 8 +------- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/beets/library.py b/beets/library.py index c5503dd0a..0c1acba22 100644 --- a/beets/library.py +++ b/beets/library.py @@ -1107,11 +1107,12 @@ def parse_query_string(s, model_cls): The string is split into components using shell-like syntax. """ + assert isinstance(s, unicode), "Query is not unicode: {0!r}".format(s) + # A bug in Python < 2.7.3 prevents correct shlex splitting of # Unicode strings. # http://bugs.python.org/issue6988 - if isinstance(s, unicode): - s = s.encode('utf8') + s = s.encode('utf8') try: parts = [p.decode('utf8') for p in shlex.split(s)] except ValueError as exc: diff --git a/test/test_library.py b/test/test_library.py index 6bb88076e..9ec43c2bf 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -1195,6 +1195,10 @@ class ParseQueryTest(unittest.TestCase): self.assertIsInstance(raised.exception, beets.dbcore.query.ParsingError) + def test_parse_bytes(self): + with self.assertRaises(AssertionError): + beets.library.parse_query_string(b"query", None) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/test/test_query.py b/test/test_query.py index e53efc29f..2511aa841 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -465,7 +465,7 @@ class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin): def test_case_sensitivity(self): self.add_album(path='/A/B/C2.mp3', title='caps path') - q = b'path:/A/B' + q = 'path:/A/B' with patch('beets.library.PathQuery._is_windows', False): results = self.lib.items(q) self.assert_items_matched(results, ['caps path']) @@ -474,12 +474,6 @@ class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin): results = self.lib.items(q) self.assert_items_matched(results, ['path item', 'caps path']) - def test_utf8_bytes(self): - self.add_album(path=b'/\xc3\xa0/b/c.mp3', title='latin byte') - q = b'path:/\xc3\xa0/b/c.mp3' - results = self.lib.items(q) - self.assert_items_matched(results, ['latin byte']) - class IntQueryTest(unittest.TestCase, TestHelper): From 9efcfbb8fa32c1a4ac2a2147a6250414f0d53d0c Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Sun, 1 Mar 2015 18:10:07 +0100 Subject: [PATCH 11/28] PathQuery: add 'case_sensitivity' param - fully tested - default value is platform-aware --- beets/library.py | 16 +++++++++++----- test/test_query.py | 20 +++++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/beets/library.py b/beets/library.py index 0c1acba22..b6d8f4c07 100644 --- a/beets/library.py +++ b/beets/library.py @@ -45,7 +45,8 @@ log = logging.getLogger('beets') class PathQuery(dbcore.FieldQuery): """A query that matches all items under a given path. - On Windows paths are case-insensitive, contratly to UNIX platforms. + On Windows paths are case-insensitive by default, contrarly to UNIX + platforms. """ escape_re = re.compile(r'[\\_%]') @@ -53,11 +54,16 @@ class PathQuery(dbcore.FieldQuery): _is_windows = platform.system() == 'Windows' - def __init__(self, field, pattern, fast=True): + def __init__(self, field, pattern, fast=True, case_sensitive=None): super(PathQuery, self).__init__(field, pattern, fast) - if self._is_windows: + if case_sensitive is None: + # setting this value as the default one would make it un-patchable + # and therefore un-testable + case_sensitive = not self._is_windows + if not case_sensitive: pattern = pattern.lower() + self.case_sensitive = case_sensitive # Match the path as a single file. self.file_path = util.bytestring_path(util.normpath(pattern)) @@ -65,13 +71,13 @@ class PathQuery(dbcore.FieldQuery): self.dir_path = util.bytestring_path(os.path.join(self.file_path, b'')) def match(self, item): - path = item.path.lower() if self._is_windows else item.path + path = item.path if self.case_sensitive else item.path.lower() return (path == self.file_path) or path.startswith(self.dir_path) def col_clause(self): file_blob = buffer(self.file_path) - if not self._is_windows: + if self.case_sensitive: dir_blob = buffer(self.dir_path) return '({0} = ?) || (substr({0}, 1, ?) = ?)'.format(self.field), \ (file_blob, len(dir_blob), dir_blob) diff --git a/test/test_query.py b/test/test_query.py index 2511aa841..ee0f3d0ba 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -17,6 +17,7 @@ from __future__ import (division, absolute_import, print_function, unicode_literals) +from functools import partial from mock import patch from test import _common @@ -465,14 +466,23 @@ class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin): def test_case_sensitivity(self): self.add_album(path='/A/B/C2.mp3', title='caps path') - q = 'path:/A/B' + + makeq = partial(beets.library.PathQuery, 'path', '/A/B') + + results = self.lib.items(makeq(case_sensitive=True)) + self.assert_items_matched(results, ['caps path']) + + results = self.lib.items(makeq(case_sensitive=False)) + self.assert_items_matched(results, ['path item', 'caps path']) + + # test platform-aware default sensitivity with patch('beets.library.PathQuery._is_windows', False): - results = self.lib.items(q) - self.assert_items_matched(results, ['caps path']) + q = makeq() + self.assertEqual(q.case_sensitive, True) with patch('beets.library.PathQuery._is_windows', True): - results = self.lib.items(q) - self.assert_items_matched(results, ['path item', 'caps path']) + q = makeq() + self.assertEqual(q.case_sensitive, False) class IntQueryTest(unittest.TestCase, TestHelper): From ddf86af3a0fa1129bb12781aa196dbc7532aa585 Mon Sep 17 00:00:00 2001 From: Taeyeon Mori Date: Sun, 1 Mar 2015 19:49:31 +0100 Subject: [PATCH 12/28] DOCS The plugin stages now receive the ImportSession as first argument --- docs/dev/plugins.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst index f448b5dfa..79a3f7354 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins.rst @@ -382,7 +382,7 @@ Multiple stages run in parallel but each stage processes only one task at a time and each task is processed by only one stage at a time. Plugins provide stages as functions that take two arguments: ``config`` and -``task``, which are ``ImportConfig`` and ``ImportTask`` objects (both defined in +``task``, which are ``ImportSession`` and ``ImportTask`` objects (both defined in ``beets.importer``). Add such a function to the plugin's ``import_stages`` field to register it:: @@ -391,7 +391,7 @@ to register it:: def __init__(self): super(ExamplePlugin, self).__init__() self.import_stages = [self.stage] - def stage(self, config, task): + def stage(self, session, task): print('Importing something!') .. _extend-query: From eec8d5d2be922cd295f7888f656eef2545346168 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 1 Mar 2015 17:09:36 -0800 Subject: [PATCH 13/28] Doc rewording for #1330 --- beets/library.py | 17 ++++++++++++----- docs/changelog.rst | 2 +- docs/reference/query.rst | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/beets/library.py b/beets/library.py index fe9bee081..9b22fd4df 100644 --- a/beets/library.py +++ b/beets/library.py @@ -45,8 +45,9 @@ log = logging.getLogger('beets') class PathQuery(dbcore.FieldQuery): """A query that matches all items under a given path. - On Windows paths are case-insensitive by default, contrarly to UNIX - platforms. + Matching can either base case-sensitive or case-sensitive. By + default, the behavior depends on the OS: case-insensitive on Windows + and case-sensitive otherwise. """ escape_re = re.compile(r'[\\_%]') @@ -55,15 +56,21 @@ class PathQuery(dbcore.FieldQuery): _is_windows = platform.system() == 'Windows' def __init__(self, field, pattern, fast=True, case_sensitive=None): + """Create a path query. + + `case_sensitive` can be a bool or `None`, indicating that the + behavior should depend on the platform (the default). + """ super(PathQuery, self).__init__(field, pattern, fast) + # By default, the case sensitivity depends on the platform. if case_sensitive is None: - # setting this value as the default one would make it un-patchable - # and therefore un-testable case_sensitive = not self._is_windows + self.case_sensitive = case_sensitive + + # Use a normalized-case pattern for case-insensitive matches. if not case_sensitive: pattern = pattern.lower() - self.case_sensitive = case_sensitive # Match the path as a single file. self.file_path = util.bytestring_path(util.normpath(pattern)) diff --git a/docs/changelog.rst b/docs/changelog.rst index 449eb4017..11a64d3ad 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -76,7 +76,7 @@ Fixes: * :doc:`/plugins/replaygain`: Stop applying replaygain directly to source files when using the mp3gain backend. :bug:`1316` -* Path queries are case-sensitive on UNIX OSes. :bug:`1165` +* Path queries are case-sensitive on non-Windows OSes. :bug:`1165` * :doc:`/plugins/lyrics`: Silence a warning about insecure requests in the new MusixMatch backend. :bug:`1204` * Fix a crash when ``beet`` is invoked without arguments. :bug:`1205` diff --git a/docs/reference/query.rst b/docs/reference/query.rst index af676a50d..20c5360f8 100644 --- a/docs/reference/query.rst +++ b/docs/reference/query.rst @@ -184,7 +184,7 @@ Note that this only matches items that are *already in your library*, so a path query won't necessarily find *all* the audio files in a directory---just the ones you've already added to your beets library. -Such queries are case-sensitive on UNIX and case-insensitive on Microsoft +Path queries are case-sensitive on most platforms but case-insensitive on Windows. .. _query-sort: From 31c7c4a87735eb1dcb1ffe0616c62760a4d84ccc Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 1 Mar 2015 17:11:59 -0800 Subject: [PATCH 14/28] Avoid a little global state (#1330) For even clearer interaction with the environment. --- beets/library.py | 4 +--- test/test_query.py | 5 ++--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/beets/library.py b/beets/library.py index 9b22fd4df..793e5ca40 100644 --- a/beets/library.py +++ b/beets/library.py @@ -53,8 +53,6 @@ class PathQuery(dbcore.FieldQuery): escape_re = re.compile(r'[\\_%]') escape_char = b'\\' - _is_windows = platform.system() == 'Windows' - def __init__(self, field, pattern, fast=True, case_sensitive=None): """Create a path query. @@ -65,7 +63,7 @@ class PathQuery(dbcore.FieldQuery): # By default, the case sensitivity depends on the platform. if case_sensitive is None: - case_sensitive = not self._is_windows + case_sensitive = platform.system() != 'Windows' self.case_sensitive = case_sensitive # Use a normalized-case pattern for case-insensitive matches. diff --git a/test/test_query.py b/test/test_query.py index ee0f3d0ba..4334488df 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -18,7 +18,6 @@ from __future__ import (division, absolute_import, print_function, unicode_literals) from functools import partial -from mock import patch from test import _common from test._common import unittest @@ -476,11 +475,11 @@ class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin): self.assert_items_matched(results, ['path item', 'caps path']) # test platform-aware default sensitivity - with patch('beets.library.PathQuery._is_windows', False): + with _common.platform_posix(): q = makeq() self.assertEqual(q.case_sensitive, True) - with patch('beets.library.PathQuery._is_windows', True): + with _common.platform_windows(): q = makeq() self.assertEqual(q.case_sensitive, False) From 9c4492752ff1b3e16c61d8be867d7688de811058 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 1 Mar 2015 17:33:11 -0800 Subject: [PATCH 15/28] Fix a test fix :flushed: for #1330 --- test/test_query.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_query.py b/test/test_query.py index 4334488df..d512e02b8 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -475,11 +475,11 @@ class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin): self.assert_items_matched(results, ['path item', 'caps path']) # test platform-aware default sensitivity - with _common.platform_posix(): + with _common.system_mock('Darwin'): q = makeq() self.assertEqual(q.case_sensitive, True) - with _common.platform_windows(): + with _common.system_mock('Windows'): q = makeq() self.assertEqual(q.case_sensitive, False) From 226a90d12a85abc2fa19efde23feacec97d7dc34 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Mon, 2 Mar 2015 08:51:59 +0100 Subject: [PATCH 16/28] PathQuery: fix docstring --- beets/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/library.py b/beets/library.py index 793e5ca40..0dd3acaf1 100644 --- a/beets/library.py +++ b/beets/library.py @@ -45,7 +45,7 @@ log = logging.getLogger('beets') class PathQuery(dbcore.FieldQuery): """A query that matches all items under a given path. - Matching can either base case-sensitive or case-sensitive. By + Matching can either be case-insensitive or case-sensitive. By default, the behavior depends on the OS: case-insensitive on Windows and case-sensitive otherwise. """ From 72c5db88765bafb4e43ce072e5bdd21c37467f45 Mon Sep 17 00:00:00 2001 From: jean-marie winters Date: Mon, 2 Mar 2015 15:38:33 +0100 Subject: [PATCH 17/28] add doc, clean-up code --- beetsplug/replaygain.py | 114 +++++++++++++++++++++++++++++++++++- docs/plugins/replaygain.rst | 31 +++++++++- 2 files changed, 139 insertions(+), 6 deletions(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index ce41cad57..cd0022846 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -21,6 +21,7 @@ import collections import itertools import sys import warnings +import re from beets import logging from beets import ui @@ -32,12 +33,14 @@ from beets import config # Utilities. class ReplayGainError(Exception): + """Raised when a local (to a track or an album) error occurs in one of the backends. """ class FatalReplayGainError(Exception): + """Raised when a fatal error occurs in one of the backends. """ @@ -66,8 +69,10 @@ AlbumGain = collections.namedtuple("AlbumGain", "album_gain track_gains") class Backend(object): + """An abstract class representing engine for calculating RG values. """ + def __init__(self, config, log): """Initialize the backend with the configuration view for the plugin. @@ -83,10 +88,108 @@ class Backend(object): raise NotImplementedError() +# bsg1770gain backend + + +class Bs1770gainBackend(Backend): + + def __init__(self, config, log): + super(Bs1770gainBackend, self).__init__(config, log) + cmd = 'bs1770gain' + + try: + self.method = '--' + config['method'].get(unicode) + except: + self.method = '--replaygain' + + try: + call([cmd, self.method]) + self.command = cmd + except OSError: + pass + if not self.command: + raise FatalReplayGainError( + 'no replaygain command found: install bs1770gain' + ) + + def compute_track_gain(self, items): + """Computes the track gain of the given tracks, returns a list + of TrackGain objects. + """ + output = self.compute_gain(items, False) + return output + + def compute_album_gain(self, album): + """Computes the album gain of the given album, returns an + AlbumGain object. + """ + # TODO: What should be done when not all tracks in the album are + # supported? + + supported_items = album.items() + output = self.compute_gain(supported_items, True) + + return AlbumGain(output[-1], output[:-1]) + + def compute_gain(self, items, is_album): + """Computes the track or album gain of a list of items, returns + a list of TrackGain objects. + When computing album gain, the last TrackGain object returned is + the album gain + """ + + if len(items) == 0: + return [] + + """Compute ReplayGain values and return a list of results + dictionaries as given by `parse_tool_output`. + """ + # Construct shell command. + cmd = [self.command] + cmd = cmd + [self.method] + cmd = cmd + ['-it'] + cmd = cmd + [syspath(i.path) for i in items] + + self._log.debug(u'analyzing {0} files', len(items)) + self._log.debug(u"executing {0}", " ".join(map(displayable_path, cmd))) + output = call(cmd) + self._log.debug(u'analysis finished') + results = self.parse_tool_output(output, + len(items) + is_album) + return results + + def parse_tool_output(self, text, num_lines): + """Given the output from bs1770gain, parse the text and + return a list of dictionaries + containing information about each analyzed file. + """ + out = [] + data = unicode(text, errors='ignore') + regex = ("(\s{2,2}\[\d+\/\d+\].*?|\[ALBUM\].*?)(?=\s{2,2}\[\d+\/\d+\]" + "|\s{2,2}\[ALBUM\]:|done\.$)") + + results = re.findall(regex, data, re.S | re.M) + for ll in results[0:num_lines]: + parts = ll.split(b'\n') + if len(parts) == 0: + self._log.debug(u'bad tool output: {0}', text) + raise ReplayGainError('bs1770gain failed') + + d = { + 'file': parts[0], + 'gain': float((parts[1].split('/'))[1].split('LU')[0]), + 'peak': float(parts[2].split('/')[1]), + } + + self._log.info('analysed {}gain={};peak={}', + d['file'].rstrip(), d['gain'], d['peak']) + out.append(Gain(d['gain'], d['peak'])) + return out + + # mpgain/aacgain CLI tool backend. - - class CommandBackend(Backend): + def __init__(self, config, log): super(CommandBackend, self).__init__(config, log) config.add({ @@ -218,6 +321,7 @@ class CommandBackend(Backend): # GStreamer-based backend. class GStreamerBackend(Backend): + def __init__(self, config, log): super(GStreamerBackend, self).__init__(config, log) self._import_gst() @@ -466,10 +570,12 @@ class GStreamerBackend(Backend): class AudioToolsBackend(Backend): + """ReplayGain backend that uses `Python Audio Tools `_ and its capabilities to read more file formats and compute ReplayGain values using it replaygain module. """ + def __init__(self, config, log): super(CommandBackend, self).__init__(config, log) self._import_audiotools() @@ -594,13 +700,15 @@ class AudioToolsBackend(Backend): # Main plugin logic. class ReplayGainPlugin(BeetsPlugin): + """Provides ReplayGain analysis. """ backends = { "command": CommandBackend, "gstreamer": GStreamerBackend, - "audiotools": AudioToolsBackend + "audiotools": AudioToolsBackend, + "bs1770gain": Bs1770gainBackend } def __init__(self): diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst index d2584a648..8c0bf610d 100644 --- a/docs/plugins/replaygain.rst +++ b/docs/plugins/replaygain.rst @@ -10,9 +10,9 @@ playback levels. Installation ------------ -This plugin can use one of three backends to compute the ReplayGain values: -GStreamer, mp3gain (and its cousin, aacgain), and Python Audio Tools. mp3gain -can be easier to install but GStreamer and Audio Tools support more audio +This plugin can use one of four backends to compute the ReplayGain values: +GStreamer, mp3gain (and its cousin, aacgain), Python Audio Tools and bs1770gain. mp3gain +can be easier to install but GStreamer, Audio Tools and bs1770gain support more audio formats. Once installed, this plugin analyzes all files during the import process. This @@ -75,6 +75,22 @@ On OS X, most of the dependencies can be installed with `Homebrew`_:: .. _Python Audio Tools: http://audiotools.sourceforge.net +bs1770gain +`````````` + +In order to use this backend, you will need to install the bs1770gain command-line tool. Here are some hints: + +* goto `bs1770gain`_ and follow the download instructions +* make sure it is in your $PATH + +.. _bs1770gain: bs1770gain.sourceforge.net + +Then, enable the plugin (see :ref:`using-plugins`) and specify the +backend in your configuration file:: + + replaygain: + backend: bs1770gain + Configuration ------------- @@ -100,6 +116,15 @@ These options only work with the "command" backend: would keep clipping from occurring. Default: ``yes``. +This option only works with the "bs1770gain" backend: + +- **method**:either replaygain, ebu or atsc. Default: replaygain + + replaygain measures loudness with a reference level of -18 LUFS. + ebu measures loudness with a reference level of -23 LUFS. + atsc measures loudness with a reference level of -24 LUFS. + + Manual Analysis --------------- From 8bd0633496da9038e020493cafadc333245ff02d Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 2 Mar 2015 11:30:14 -0800 Subject: [PATCH 18/28] replaygain: Fix `super` call in AudioTools Fix #1342. --- beetsplug/replaygain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index ce41cad57..378e9466e 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -471,7 +471,7 @@ class AudioToolsBackend(Backend): file formats and compute ReplayGain values using it replaygain module. """ def __init__(self, config, log): - super(CommandBackend, self).__init__(config, log) + super(AudioToolsBackend, self).__init__(config, log) self._import_audiotools() def _import_audiotools(self): From 5bc8ef700910aa84753a0a1fdc87f88bb938a945 Mon Sep 17 00:00:00 2001 From: jean-marie winters Date: Mon, 2 Mar 2015 22:11:33 +0100 Subject: [PATCH 19/28] fix some formating --- beetsplug/replaygain.py | 19 ++++++++----------- docs/plugins/replaygain.rst | 8 ++++---- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index c52cbe053..a13b58985 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -88,12 +88,14 @@ class Backend(object): raise NotImplementedError() - # bsg1770gain backend - - class Bs1770gainBackend(Backend): + """bs1770gain is a loudness scanner compliant with ITU-R BS.1770 and its + flavors EBU R128,ATSC A/85 and Replaygain 2.0. It uses a special + designed algorithm to normalize audio to the same level. + """ + def __init__(self, config, log): super(Bs1770gainBackend, self).__init__(config, log) cmd = 'bs1770gain' @@ -107,19 +109,17 @@ class Bs1770gainBackend(Backend): call([cmd, self.method]) self.command = cmd except OSError: - pass + pass if not self.command: raise FatalReplayGainError( 'no replaygain command found: install bs1770gain' ) - def compute_track_gain(self, items): """Computes the track gain of the given tracks, returns a list of TrackGain objects. """ - output = self.compute_gain(items, False) return output @@ -135,8 +135,6 @@ class Bs1770gainBackend(Backend): return AlbumGain(output[-1], output[:-1]) - - def compute_gain(self, items, is_album): """Computes the track or album gain of a list of items, returns a list of TrackGain objects. @@ -147,7 +145,6 @@ class Bs1770gainBackend(Backend): if len(items) == 0: return [] - """Compute ReplayGain values and return a list of results dictionaries as given by `parse_tool_output`. """ @@ -171,7 +168,7 @@ class Bs1770gainBackend(Backend): containing information about each analyzed file. """ out = [] - data = unicode(text, errors='ignore') + data = text.decode('utf8', errors='ignore') regex = ("(\s{2,2}\[\d+\/\d+\].*?|\[ALBUM\].*?)(?=\s{2,2}\[\d+\/\d+\]" "|\s{2,2}\[ALBUM\]:|done\.$)") @@ -179,7 +176,7 @@ class Bs1770gainBackend(Backend): for ll in results[0:num_lines]: parts = ll.split(b'\n') if len(parts) == 0: - self._log.debug(u'bad tool output: {0}', text) + self._log.debug(u'bad tool output: {0!r}', text) raise ReplayGainError('bs1770gain failed') d = { diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst index 8c0bf610d..f08a23953 100644 --- a/docs/plugins/replaygain.rst +++ b/docs/plugins/replaygain.rst @@ -118,11 +118,11 @@ These options only work with the "command" backend: This option only works with the "bs1770gain" backend: -- **method**:either replaygain, ebu or atsc. Default: replaygain +- **method**: The loudness scanning standard: either 'replaygain' for ReplayGain 2.0, + 'ebu' for EBU R128 or 'atsc' for ATSC A/85. + This dictates the reference level: -18, -23, or -24 LUFS respectively. Default: replaygain + - replaygain measures loudness with a reference level of -18 LUFS. - ebu measures loudness with a reference level of -23 LUFS. - atsc measures loudness with a reference level of -24 LUFS. Manual Analysis From 80c49ab36098398a4fbcb2a0ec920f4385e151ab Mon Sep 17 00:00:00 2001 From: jean-marie winters Date: Tue, 3 Mar 2015 11:04:03 +0100 Subject: [PATCH 20/28] removed line 288 --- beetsplug/replaygain.py | 1 - 1 file changed, 1 deletion(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index bbaa47d0b..dcb98ff3a 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -286,7 +286,6 @@ class CommandBackend(Backend): else: # Disable clipping warning. cmd = cmd + ['-c'] - cmd = cmd + ['-a' if is_album else '-r'] cmd = cmd + ['-d', bytes(self.gain_offset)] cmd = cmd + [syspath(i.path) for i in items] From a3e32fd410118e9f479cce53bc8e487d7d434b1d Mon Sep 17 00:00:00 2001 From: jean-marie winters Date: Tue, 3 Mar 2015 11:23:45 +0100 Subject: [PATCH 21/28] added fatalreplaygainerror --- beetsplug/replaygain.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index dcb98ff3a..39d1f0382 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -109,7 +109,9 @@ class Bs1770gainBackend(Backend): call([cmd, self.method]) self.command = cmd except OSError: - pass + raise FatalReplayGainError( + 'Is bs1770gain installed? Is your method in conifg correct?' + ) if not self.command: raise FatalReplayGainError( 'no replaygain command found: install bs1770gain' From 5d7d402adb5642e8604aa766b2efc088b2baa00e Mon Sep 17 00:00:00 2001 From: jmwatte Date: Tue, 3 Mar 2015 13:11:25 +0100 Subject: [PATCH 22/28] correct typing error --- beetsplug/replaygain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 39d1f0382..e9d71619f 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -110,7 +110,7 @@ class Bs1770gainBackend(Backend): self.command = cmd except OSError: raise FatalReplayGainError( - 'Is bs1770gain installed? Is your method in conifg correct?' + 'Is bs1770gain installed? Is your method in config correct?' ) if not self.command: raise FatalReplayGainError( From 48671cbdf1cba0ae7dedf63b037571b49a1465a3 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 3 Mar 2015 10:38:01 -0800 Subject: [PATCH 23/28] Changelog for #1343 --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 11a64d3ad..ab9918ae3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,6 +6,8 @@ Changelog Features: +* :doc:`/plugins/replaygain`: There is a new backend for the `bs1770gain`_ + tool. Thanks to :user:`jmwatte`. :bug:`1343` * There are now multiple levels of verbosity. On the command line, you can make beets somewhat verbose with ``-v`` or very verbose with ``-vv``. :bug:`1244` @@ -129,6 +131,8 @@ For developers: immediately after they are initialized. It's also possible to replace the originally created tasks by returning new ones using this event. +.. _bs1770gain: http://bs1770gain.sourceforge.net + 1.3.10 (January 5, 2015) ------------------------ From 8113c7ba85febeb8c976081adecae6f0e04e5eef Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 3 Mar 2015 10:40:53 -0800 Subject: [PATCH 24/28] Roll back whitespace changes from #1343 --- beetsplug/replaygain.py | 12 ++---------- docs/plugins/replaygain.rst | 12 ++++++------ 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index e9d71619f..c4bebc8dd 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -33,14 +33,12 @@ from beets import config # Utilities. class ReplayGainError(Exception): - """Raised when a local (to a track or an album) error occurs in one of the backends. """ class FatalReplayGainError(Exception): - """Raised when a fatal error occurs in one of the backends. """ @@ -69,7 +67,6 @@ AlbumGain = collections.namedtuple("AlbumGain", "album_gain track_gains") class Backend(object): - """An abstract class representing engine for calculating RG values. """ @@ -90,10 +87,8 @@ class Backend(object): # bsg1770gain backend class Bs1770gainBackend(Backend): - - """bs1770gain is a loudness scanner compliant with ITU-R BS.1770 and its - flavors EBU R128,ATSC A/85 and Replaygain 2.0. It uses a special - designed algorithm to normalize audio to the same level. + """bs1770gain is a loudness scanner compliant with ITU-R BS.1770 and + its flavors EBU R128, ATSC A/85 and Replaygain 2.0. """ def __init__(self, config, log): @@ -576,7 +571,6 @@ class GStreamerBackend(Backend): class AudioToolsBackend(Backend): - """ReplayGain backend that uses `Python Audio Tools `_ and its capabilities to read more file formats and compute ReplayGain values using it replaygain module. @@ -706,7 +700,6 @@ class AudioToolsBackend(Backend): # Main plugin logic. class ReplayGainPlugin(BeetsPlugin): - """Provides ReplayGain analysis. """ @@ -802,7 +795,6 @@ class ReplayGainPlugin(BeetsPlugin): ) self.store_album_gain(album, album_gain.album_gain) - for item, track_gain in itertools.izip(album.items(), album_gain.track_gains): self.store_track_gain(item, track_gain) diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst index f08a23953..b8f385df8 100644 --- a/docs/plugins/replaygain.rst +++ b/docs/plugins/replaygain.rst @@ -90,7 +90,8 @@ backend in your configuration file:: replaygain: backend: bs1770gain - + + Configuration ------------- @@ -118,11 +119,10 @@ These options only work with the "command" backend: This option only works with the "bs1770gain" backend: -- **method**: The loudness scanning standard: either 'replaygain' for ReplayGain 2.0, - 'ebu' for EBU R128 or 'atsc' for ATSC A/85. - This dictates the reference level: -18, -23, or -24 LUFS respectively. Default: replaygain - - +- **method**: The loudness scanning standard: either `replaygain` for + ReplayGain 2.0, `ebu` for EBU R128, or `atsc` for ATSC A/85. This dictates + the reference level: -18, -23, or -24 LUFS respectively. Default: + `replaygain` Manual Analysis From 69786b8538caa01c1cea772b1f14f10236f70fd5 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 4 Mar 2015 12:09:40 +0100 Subject: [PATCH 25/28] Fix test.helper.has_program(): encode command subprocess.subprocess.check_call() should receive a byte string command otherwise it fails with a UnicodeDecodeError on systems with non-ascii elements in the system path. Discovered while reviewing PR #1344. --- test/helper.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/helper.py b/test/helper.py index 8d0dbf8a6..bb32c1c87 100644 --- a/test/helper.py +++ b/test/helper.py @@ -51,6 +51,7 @@ from beets.library import Library, Item, Album from beets import importer from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.mediafile import MediaFile, Image +from beets.ui import _encoding # TODO Move AutotagMock here from test import _common @@ -117,9 +118,13 @@ def capture_stdout(): def has_program(cmd, args=['--version']): """Returns `True` if `cmd` can be executed. """ + full_cmd = [cmd] + args + for i, elem in enumerate(full_cmd): + if isinstance(elem, unicode): + full_cmd[i] = elem.encode(_encoding()) try: with open(os.devnull, 'wb') as devnull: - subprocess.check_call([cmd] + args, stderr=devnull, + subprocess.check_call(full_cmd, stderr=devnull, stdout=devnull, stdin=devnull) except OSError: return False From f8e2ca2c944d5dd2c1d7cec46372d0098192ec1b Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 4 Mar 2015 12:15:56 +0100 Subject: [PATCH 26/28] Replaygain tests: more careful plugins unloading When plugins loading as failed plugins unloading may fail in consequence, swallowing the loading error. This fixes it. --- test/test_replaygain.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/test/test_replaygain.py b/test/test_replaygain.py index 64d65b006..2c623725c 100644 --- a/test/test_replaygain.py +++ b/test/test_replaygain.py @@ -42,9 +42,18 @@ class ReplayGainCliTestBase(TestHelper): try: self.load_plugins('replaygain') except: - self.teardown_beets() - self.unload_plugins() - raise + import sys + # store exception info so an error in teardown does not swallow it + exc_info = sys.exc_info() + try: + self.teardown_beets() + self.unload_plugins() + except: + # if load_plugins() failed then setup is incomplete and + # teardown operations may fail. In particular # {Item,Album} + # may not have the _original_types attribute in unload_plugins + pass + raise exc_info[1], None, exc_info[2] self.config['replaygain']['backend'] = self.backend album = self.add_album_fixture(2) From 9750351a1b6b98136a32f9d9b2b23f034479f593 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 4 Mar 2015 14:48:17 +0100 Subject: [PATCH 27/28] beets.util.command_output() & related receives bytes - May fail with unicode, esp. will non-ascii system path entry. - Only send it bytes. - Also applies to subprocess.Popen() & co. --- beets/util/__init__.py | 6 +++--- beets/util/artresizer.py | 6 +++--- beetsplug/embedart.py | 6 +++--- beetsplug/replaygain.py | 18 +++++++++--------- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 2d6968e13..53156143f 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -624,7 +624,7 @@ def cpu_count(): num = 0 elif sys.platform == b'darwin': try: - num = int(command_output(['sysctl', '-n', 'hw.ncpu'])) + num = int(command_output([b'sysctl', b'-n', b'hw.ncpu'])) except ValueError: num = 0 else: @@ -641,8 +641,8 @@ def cpu_count(): def command_output(cmd, shell=False): """Runs the command and returns its output after it has exited. - ``cmd`` is a list of arguments starting with the command names. If - ``shell`` is true, ``cmd`` is assumed to be a string and passed to a + ``cmd`` is a list of byte string arguments starting with the command names. + If ``shell`` is true, ``cmd`` is assumed to be a string and passed to a shell to execute. If the process exits with a non-zero return code diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index b1920d8ac..bce888209 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -91,8 +91,8 @@ def im_resize(maxwidth, path_in, path_out=None): # compatibility. try: util.command_output([ - 'convert', util.syspath(path_in), - '-resize', '{0}x^>'.format(maxwidth), path_out + b'convert', util.syspath(path_in), + b'-resize', b'{0}x^>'.format(maxwidth), path_out ]) except subprocess.CalledProcessError: log.warn(u'artresizer: IM convert failed for {0}', @@ -187,7 +187,7 @@ class ArtResizer(object): # Try invoking ImageMagick's "convert". try: - out = util.command_output(['identify', '--version']) + out = util.command_output([b'identify', b'--version']) if 'imagemagick' in out.lower(): pattern = r".+ (\d+)\.(\d+)\.(\d+).*" diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index 35feb4d9c..d44095687 100644 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -196,13 +196,13 @@ class EmbedCoverArtPlugin(BeetsPlugin): # Converting images to grayscale tends to minimize the weight # of colors in the diff score. convert_proc = subprocess.Popen( - ['convert', syspath(imagepath), syspath(art), - '-colorspace', 'gray', 'MIFF:-'], + [b'convert', syspath(imagepath), syspath(art), + b'-colorspace', b'gray', b'MIFF:-'], stdout=subprocess.PIPE, close_fds=not is_windows, ) compare_proc = subprocess.Popen( - ['compare', '-metric', 'PHASH', '-', 'null:'], + [b'compare', b'-metric', b'PHASH', b'-', b'null:'], stdin=convert_proc.stdout, stdout=subprocess.PIPE, stderr=subprocess.PIPE, diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index c4bebc8dd..41937d455 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -93,12 +93,12 @@ class Bs1770gainBackend(Backend): def __init__(self, config, log): super(Bs1770gainBackend, self).__init__(config, log) - cmd = 'bs1770gain' + cmd = b'bs1770gain' try: - self.method = '--' + config['method'].get(unicode) + self.method = b'--' + config['method'].get(str) except: - self.method = '--replaygain' + self.method = b'--replaygain' try: call([cmd, self.method]) @@ -210,9 +210,9 @@ class CommandBackend(Backend): ) else: # Check whether the program is in $PATH. - for cmd in ('mp3gain', 'aacgain'): + for cmd in (b'mp3gain', b'aacgain'): try: - call([cmd, '-v']) + call([cmd, b'-v']) self.command = cmd except OSError: pass @@ -276,14 +276,14 @@ class CommandBackend(Backend): # tag-writing; this turns the mp3gain/aacgain tool into a gain # calculator rather than a tag manipulator because we take care # of changing tags ourselves. - cmd = [self.command, '-o', '-s', 's'] + cmd = [self.command, b'-o', b'-s', b's'] if self.noclip: # Adjust to avoid clipping. - cmd = cmd + ['-k'] + cmd = cmd + [b'-k'] else: # Disable clipping warning. - cmd = cmd + ['-c'] - cmd = cmd + ['-d', bytes(self.gain_offset)] + cmd = cmd + [b'-c'] + cmd = cmd + [b'-d', bytes(self.gain_offset)] cmd = cmd + [syspath(i.path) for i in items] self._log.debug(u'analyzing {0} files', len(items)) From 5a355201d3de084281c1e3f00f7b0e6650b46557 Mon Sep 17 00:00:00 2001 From: Bruno Cauet Date: Wed, 4 Mar 2015 15:29:19 +0100 Subject: [PATCH 28/28] =?UTF-8?q?test=5Freplaygain:=20fix=20except=20a,=20?= =?UTF-8?q?b:=20=E2=86=92=20except=20(a,=20b):?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/test_replaygain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_replaygain.py b/test/test_replaygain.py index 2c623725c..fe1bdfea5 100644 --- a/test/test_replaygain.py +++ b/test/test_replaygain.py @@ -25,7 +25,7 @@ try: import gi gi.require_version('Gst', '1.0') GST_AVAILABLE = True -except ImportError, ValueError: +except (ImportError, ValueError): GST_AVAILABLE = False if any(has_program(cmd, ['-v']) for cmd in ['mp3gain', 'aacgain']):