From 67eb0ed54c60d2cab69616238aa802e98838cdcf Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Fri, 4 Dec 2015 20:51:25 +0100 Subject: [PATCH 01/10] Format track duration as H:MM instead of seconds * Modify library.Item in order to have length formatted as H:MM instead of the raw number of seconds by using a types.Float subclass (DurationType). * Add library.DurationType, with custom format() and parse() methods that handle the conversion. * Add dbcore.query.DurationQuery as a NumericQuery subclass that _convert()s the ranges specified by the user to floats, delegating the rest of the functionality in the parent NumericQuery class. * Add ui.raw_seconds_short() as the reverse of human_seconds_short(). This function uses a regular expression in order to allow any number of minutes, and always required SS to have two digits. --- beets/dbcore/query.py | 29 +++++++++++++++++++++++++++++ beets/library.py | 21 ++++++++++++++++++++- beets/ui/__init__.py | 13 +++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index f0adac665..9308ba0b3 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -653,6 +653,35 @@ class DateQuery(FieldQuery): return clause, subvals +class DurationQuery(NumericQuery): + """NumericQuery that allow human-friendly (M:SS) time interval formats. + + Converts the range(s) to a float value, and delegates on NumericQuery. + + Raises InvalidQueryError when the pattern does not represent an int, float + or M:SS time interval. + """ + def _convert(self, s): + """Convert a M:SS or numeric string to a float. + + Return None if `s` is empty. + Raise an InvalidQueryError if the string cannot be converted. + """ + if not s: + return None + try: + # TODO: tidy up circular import + from beets.ui import raw_seconds_short + return raw_seconds_short(s) + except ValueError: + try: + return float(s) + except ValueError: + raise InvalidQueryArgumentTypeError( + s, + "a M:SS string or a float") + + # Sorting. class Sort(object): diff --git a/beets/library.py b/beets/library.py index 870c46856..13b0b92fa 100644 --- a/beets/library.py +++ b/beets/library.py @@ -195,6 +195,25 @@ class MusicalKey(types.String): return self.parse(key) +class DurationType(types.Float): + """Human-friendly (M:SS) representation of a time interval.""" + query = dbcore.query.DurationQuery + + def format(self, value): + return beets.ui.human_seconds_short(value or 0.0) + + def parse(self, string): + try: + # Try to format back hh:ss to seconds. + return beets.ui.raw_seconds_short(value) + except ValueError: + # Fall back to a plain float.. + try: + return float(string) + except ValueError: + return self.null + + # Library-specific sort types. class SmartArtistSort(dbcore.query.Sort): @@ -426,7 +445,7 @@ class Item(LibModel): 'original_day': types.PaddedInt(2), 'initial_key': MusicalKey(), - 'length': types.FLOAT, + 'length': DurationType(), 'bitrate': types.ScaledInt(1000, u'kbps'), 'format': types.STRING, 'samplerate': types.ScaledInt(1000, u'kHz'), diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index c51c3acb6..10266b537 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -416,6 +416,19 @@ def human_seconds_short(interval): return u'%i:%02i' % (interval // 60, interval % 60) +def raw_seconds_short(string): + """Formats a human-readable M:SS string as a float (number of seconds). + + Raises ValueError if the conversion cannot take place due to `string` not + being in the right format. + """ + match = re.match('^(\d+):([0-5]\d)$', string) + if not match: + raise ValueError('String not in M:SS format') + minutes, seconds = map(int, match.groups()) + return float(minutes*60 + seconds) + + # Colorization. # ANSI terminal colorization code heavily inspired by pygments: From 8d2fda790bf72df06b05aa2a3125a1f0fcd33437 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Sat, 5 Dec 2015 13:28:56 +0100 Subject: [PATCH 02/10] Add config option for human vs raw track duration * Add "format_raw_length" global configuration option to allow the user to toggle between human-readable (default) or raw formatting of track durations. --- beets/config_default.yaml | 1 + beets/library.py | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index ba58debe7..545fa9638 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -65,6 +65,7 @@ ui: format_item: $artist - $album - $title format_album: $albumartist - $album time_format: '%Y-%m-%d %H:%M:%S' +format_raw_length: no sort_album: albumartist+ album+ sort_item: artist+ album+ disc+ track+ diff --git a/beets/library.py b/beets/library.py index 13b0b92fa..77c4bf8ab 100644 --- a/beets/library.py +++ b/beets/library.py @@ -200,7 +200,11 @@ class DurationType(types.Float): query = dbcore.query.DurationQuery def format(self, value): - return beets.ui.human_seconds_short(value or 0.0) + # TODO: decide if documenting format_raw_length + if not beets.config['format_raw_length'].get(bool): + return beets.ui.human_seconds_short(value or 0.0) + else: + return value def parse(self, string): try: From 62ee915aac952d3d0af3a3bc22cda453cde721be Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Sat, 5 Dec 2015 14:12:27 +0100 Subject: [PATCH 03/10] Fix pyflakes issues, variable name --- beets/dbcore/query.py | 4 ++-- beets/library.py | 2 +- beets/ui/__init__.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index 9308ba0b3..a33a2946a 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -678,8 +678,8 @@ class DurationQuery(NumericQuery): return float(s) except ValueError: raise InvalidQueryArgumentTypeError( - s, - "a M:SS string or a float") + s, + "a M:SS string or a float") # Sorting. diff --git a/beets/library.py b/beets/library.py index 77c4bf8ab..d8eb9d5d3 100644 --- a/beets/library.py +++ b/beets/library.py @@ -209,7 +209,7 @@ class DurationType(types.Float): def parse(self, string): try: # Try to format back hh:ss to seconds. - return beets.ui.raw_seconds_short(value) + return beets.ui.raw_seconds_short(string) except ValueError: # Fall back to a plain float.. try: diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 10266b537..3a25a03ff 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -426,7 +426,7 @@ def raw_seconds_short(string): if not match: raise ValueError('String not in M:SS format') minutes, seconds = map(int, match.groups()) - return float(minutes*60 + seconds) + return float(minutes * 60 + seconds) # Colorization. From cca307c88b5721d91de720f98f6ff70b9f325d35 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Sat, 5 Dec 2015 14:18:23 +0100 Subject: [PATCH 04/10] Fix test that was expecting raw length format --- test/test_info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_info.py b/test/test_info.py index aaabed980..4a85b6dc9 100644 --- a/test/test_info.py +++ b/test/test_info.py @@ -111,7 +111,7 @@ class InfoTest(unittest.TestCase, TestHelper): self.add_item_fixtures() out = self.run_with_output('--library', '--format', '$track. $title - $artist ($length)') - self.assertEqual(u'02. tïtle 0 - the artist (1.1)\n', out) + self.assertEqual(u'02. tïtle 0 - the artist (0:01)\n', out) def suite(): From 0e6427599387a39d0f725f7a8e724b30ccbb9bd3 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Wed, 9 Dec 2015 16:07:01 +0100 Subject: [PATCH 05/10] Add tests for library-specific field types --- test/test_library.py | 51 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/test/test_library.py b/test/test_library.py index 30b0d6fc4..04fa863ae 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -25,6 +25,7 @@ import shutil import re import unicodedata import sys +import time from test import _common from test._common import unittest @@ -1126,6 +1127,56 @@ class ParseQueryTest(unittest.TestCase): beets.library.parse_query_string(b"query", None) +class LibraryFieldTypesTest(unittest.TestCase): + """Test format() and parse() for library-specific field types""" + def test_datetype(self): + t = beets.library.DateType() + + # format + self.assertEqual('1973-11-29 22:33:09', t.format(123456789)) + # parse + self.assertEqual(123456789.0, t.parse('1973-11-29 22:33:09')) + self.assertEqual(123456789.0, t.parse('123456789.0')) + self.assertEqual(t.null, t.parse('not123456789.0')) + self.assertEqual(t.null, t.parse('1973-11-29')) + + def test_pathtype(self): + t = beets.library.PathType() + + # format + self.assertEqual('/tmp', t.format('/tmp')) + self.assertEqual(u'/tmp/\xe4lbum', t.format(u'/tmp/\u00e4lbum')) + # parse + self.assertEqual(b'/tmp', t.parse('/tmp')) + self.assertEqual(b'/tmp/\xc3\xa4lbum', t.parse(u'/tmp/\u00e4lbum/')) + + def test_musicalkey(self): + t = beets.library.MusicalKey() + + # parse + self.assertEqual('C#m', t.parse('c#m')) + self.assertEqual('Gm', t.parse('g minor')) + self.assertEqual('Not c#m', t.parse('not C#m')) + + def test_durationtype(self): + t = beets.library.DurationType() + + # format + self.assertEqual('1:01', t.format(61.23)) + self.assertEqual('60:01', t.format(3601.23)) + self.assertEqual('0:00', t.format(None)) + # parse + self.assertEqual(61.0, t.parse('1:01')) + self.assertEqual(61.23, t.parse('61.23')) + self.assertEqual(3601.0, t.parse('60:01')) + self.assertEqual(t.null, t.parse('1:00:01')) + self.assertEqual(t.null, t.parse('not61.23')) + # config format_raw_length + beets.config['format_raw_length'] = True + self.assertEqual(61.23, t.format(61.23)) + self.assertEqual(3601.23, t.format(3601.23)) + + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) From 4afdbdfdf4e78ad9b8f55432cbb6f4ea564e7a8f Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Wed, 9 Dec 2015 16:14:08 +0100 Subject: [PATCH 06/10] Move raw_seconds_short to beets.util --- beets/dbcore/query.py | 4 +--- beets/library.py | 2 +- beets/ui/__init__.py | 13 ------------- beets/util/__init__.py | 13 +++++++++++++ 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index a33a2946a..d8a3a0ea8 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -670,9 +670,7 @@ class DurationQuery(NumericQuery): if not s: return None try: - # TODO: tidy up circular import - from beets.ui import raw_seconds_short - return raw_seconds_short(s) + return util.raw_seconds_short(s) except ValueError: try: return float(s) diff --git a/beets/library.py b/beets/library.py index d8eb9d5d3..ed6b39280 100644 --- a/beets/library.py +++ b/beets/library.py @@ -209,7 +209,7 @@ class DurationType(types.Float): def parse(self, string): try: # Try to format back hh:ss to seconds. - return beets.ui.raw_seconds_short(string) + return util.raw_seconds_short(string) except ValueError: # Fall back to a plain float.. try: diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 3a25a03ff..c51c3acb6 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -416,19 +416,6 @@ def human_seconds_short(interval): return u'%i:%02i' % (interval // 60, interval % 60) -def raw_seconds_short(string): - """Formats a human-readable M:SS string as a float (number of seconds). - - Raises ValueError if the conversion cannot take place due to `string` not - being in the right format. - """ - match = re.match('^(\d+):([0-5]\d)$', string) - if not match: - raise ValueError('String not in M:SS format') - minutes, seconds = map(int, match.groups()) - return float(minutes * 60 + seconds) - - # Colorization. # ANSI terminal colorization code heavily inspired by pygments: diff --git a/beets/util/__init__.py b/beets/util/__init__.py index e2d09c3ab..55c599a05 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -843,3 +843,16 @@ def case_sensitive(path): lower = _windows_long_path_name(path.lower()) upper = _windows_long_path_name(path.upper()) return lower != upper + + +def raw_seconds_short(string): + """Formats a human-readable M:SS string as a float (number of seconds). + + Raises ValueError if the conversion cannot take place due to `string` not + being in the right format. + """ + match = re.match('^(\d+):([0-5]\d)$', string) + if not match: + raise ValueError('String not in M:SS format') + minutes, seconds = map(int, match.groups()) + return float(minutes * 60 + seconds) From a5ecc77663b806c75478c2af4e14f70a86db4b29 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Wed, 9 Dec 2015 16:30:20 +0100 Subject: [PATCH 07/10] Add documentation for M:SS length --- beets/library.py | 3 +-- docs/changelog.rst | 5 +++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/beets/library.py b/beets/library.py index ed6b39280..f8d226dbe 100644 --- a/beets/library.py +++ b/beets/library.py @@ -200,7 +200,6 @@ class DurationType(types.Float): query = dbcore.query.DurationQuery def format(self, value): - # TODO: decide if documenting format_raw_length if not beets.config['format_raw_length'].get(bool): return beets.ui.human_seconds_short(value or 0.0) else: @@ -211,7 +210,7 @@ class DurationType(types.Float): # Try to format back hh:ss to seconds. return util.raw_seconds_short(string) except ValueError: - # Fall back to a plain float.. + # Fall back to a plain float. try: return float(string) except ValueError: diff --git a/docs/changelog.rst b/docs/changelog.rst index a78bfb727..003319267 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,6 +26,11 @@ New: singles compilation, "1." See :ref:`not_query`. :bug:`819` :bug:`1728` * :doc:`/plugins/info`: The plugin now accepts the ``-f/--format`` option for customizing how items are displayed. :bug:`1737` +* Track length is now displayed as ``M:SS`` by default, instead of displaying + the raw number of seconds. Queries on track length also accept this format: + for example, ``beet list length:5:30..`` will find all your tracks that have + a duration over 5 minutes and 30 seconds. You can toggle this setting off + via the ``format_raw_length`` configuration option. :bug:`1749` For developers: From 2f2cdd24da39912db9588a98f54a7743d2d5a16e Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Wed, 9 Dec 2015 16:31:45 +0100 Subject: [PATCH 08/10] Fix unused import leftover on test_library --- test/test_library.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/test_library.py b/test/test_library.py index 04fa863ae..072d7e195 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -25,7 +25,6 @@ import shutil import re import unicodedata import sys -import time from test import _common from test._common import unittest From 25cb556ea2fc74df1fe8060621275dd11e77246b Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Wed, 9 Dec 2015 16:40:14 +0100 Subject: [PATCH 09/10] Fix test that depended on local time --- test/test_library.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/test_library.py b/test/test_library.py index 072d7e195..41d2ec8f8 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -25,6 +25,7 @@ import shutil import re import unicodedata import sys +import time from test import _common from test._common import unittest @@ -1132,7 +1133,10 @@ class LibraryFieldTypesTest(unittest.TestCase): t = beets.library.DateType() # format - self.assertEqual('1973-11-29 22:33:09', t.format(123456789)) + self.assertEqual(time.strftime(beets.config['time_format']. + get(unicode), + time.localtime(123456789)), + t.format(123456789)) # parse self.assertEqual(123456789.0, t.parse('1973-11-29 22:33:09')) self.assertEqual(123456789.0, t.parse('123456789.0')) From 3e2d2479b5b08c0550fd3c158a63ba442a5aeb71 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Wed, 9 Dec 2015 16:42:47 +0100 Subject: [PATCH 10/10] Fix test that depended on local time, 2 --- test/test_library.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/test/test_library.py b/test/test_library.py index 41d2ec8f8..860227a13 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -1133,12 +1133,11 @@ class LibraryFieldTypesTest(unittest.TestCase): t = beets.library.DateType() # format - self.assertEqual(time.strftime(beets.config['time_format']. - get(unicode), - time.localtime(123456789)), - t.format(123456789)) + time_local = time.strftime(beets.config['time_format'].get(unicode), + time.localtime(123456789)) + self.assertEqual(time_local, t.format(123456789)) # parse - self.assertEqual(123456789.0, t.parse('1973-11-29 22:33:09')) + self.assertEqual(123456789.0, t.parse(time_local)) self.assertEqual(123456789.0, t.parse('123456789.0')) self.assertEqual(t.null, t.parse('not123456789.0')) self.assertEqual(t.null, t.parse('1973-11-29'))