Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Format length as M:SS by default #1749

Merged
merged 10 commits into from
Dec 13, 2015
1 change: 1 addition & 0 deletions beets/config_default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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+
Expand Down
27 changes: 27 additions & 0 deletions beets/dbcore/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,33 @@ def col_clause(self):
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:
return util.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):
Expand Down
24 changes: 23 additions & 1 deletion beets/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,28 @@ def normalize(self, key):
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):
if not beets.config['format_raw_length'].get(bool):
Copy link
Member

Choose a reason for hiding this comment

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

FWIW, .get(bool) is implied when using a configuration view as a condition.

return beets.ui.human_seconds_short(value or 0.0)
else:
return value

def parse(self, string):
try:
# Try to format back hh:ss to seconds.
return util.raw_seconds_short(string)
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):
Expand Down Expand Up @@ -426,7 +448,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'),
Expand Down
13 changes: 13 additions & 0 deletions beets/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
5 changes: 5 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion test/test_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def test_custom_format(self):
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():
Expand Down
53 changes: 53 additions & 0 deletions test/test_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import re
import unicodedata
import sys
import time

from test import _common
from test._common import unittest
Expand Down Expand Up @@ -1126,6 +1127,58 @@ def test_parse_bytes(self):
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
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(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'))

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__)

Expand Down