mirror of
https://github.com/beetbox/beets.git
synced 2026-02-08 00:14:31 +01:00
Merge branch 'master' into import-autosingleton
This commit is contained in:
commit
754a90dc90
10 changed files with 185 additions and 53 deletions
|
|
@ -25,7 +25,7 @@ from datetime import datetime, timedelta
|
|||
|
||||
class InvalidQueryError(ValueError):
|
||||
def __init__(self, what, expected, detail=None):
|
||||
message = "{0!r} is not {1}".format(what, expected)
|
||||
message = "'{0}' is not {1}".format(what, expected)
|
||||
if detail:
|
||||
message = "{0}: {1}".format(message, detail)
|
||||
super(InvalidQueryError, self).__init__(message)
|
||||
|
|
@ -214,10 +214,14 @@ class NumericQuery(FieldQuery):
|
|||
a float.
|
||||
"""
|
||||
def _convert(self, s):
|
||||
"""Convert a string to a numeric type (float or int). If the
|
||||
string cannot be converted, return None.
|
||||
"""Convert a string to a numeric type (float or int).
|
||||
|
||||
Return None if `s` is empty.
|
||||
Raise an InvalidQueryError if the string cannot be converted.
|
||||
"""
|
||||
# This is really just a bit of fun premature optimization.
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return int(s)
|
||||
except ValueError:
|
||||
|
|
|
|||
|
|
@ -1475,7 +1475,7 @@ def albums_in_dir(path):
|
|||
match = marker_pat.match(subdir)
|
||||
if match:
|
||||
subdir_pat = re.compile(
|
||||
r'^%s\d' % re.escape(match.group(1)), re.I
|
||||
br'^%s\d' % re.escape(match.group(1)), re.I
|
||||
)
|
||||
else:
|
||||
start_collapsing = False
|
||||
|
|
@ -1497,7 +1497,7 @@ def albums_in_dir(path):
|
|||
# Set the current pattern to match directories with the same
|
||||
# prefix as this one, followed by a digit.
|
||||
collapse_pat = re.compile(
|
||||
r'^%s\d' % re.escape(match.group(1)), re.I
|
||||
br'^%s\d' % re.escape(match.group(1)), re.I
|
||||
)
|
||||
break
|
||||
|
||||
|
|
|
|||
|
|
@ -595,6 +595,11 @@ class Item(LibModel):
|
|||
"""
|
||||
return int(os.path.getmtime(syspath(self.path)))
|
||||
|
||||
def filesize(self):
|
||||
"""Returns the size, in bytes, of the file.
|
||||
"""
|
||||
return os.path.getsize(syspath(self.path))
|
||||
|
||||
# Model methods.
|
||||
|
||||
def remove(self, delete=False, with_album=True):
|
||||
|
|
@ -1069,7 +1074,10 @@ def parse_query_string(s, model_cls):
|
|||
# http://bugs.python.org/issue6988
|
||||
if isinstance(s, unicode):
|
||||
s = s.encode('utf8')
|
||||
parts = [p.decode('utf8') for p in shlex.split(s)]
|
||||
try:
|
||||
parts = [p.decode('utf8') for p in shlex.split(s)]
|
||||
except ValueError as exc:
|
||||
raise ValueError("Cannot parse {0!r} (error was: {1})".format(s, exc))
|
||||
return parse_query_parts(parts, model_cls)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -439,8 +439,10 @@ def summarize_items(items, singleton):
|
|||
|
||||
average_bitrate = sum([item.bitrate for item in items]) / len(items)
|
||||
total_duration = sum([item.length for item in items])
|
||||
total_filesize = sum([item.filesize() for item in items])
|
||||
summary_parts.append('{0}kbps'.format(int(average_bitrate / 1000)))
|
||||
summary_parts.append(ui.human_seconds_short(total_duration))
|
||||
summary_parts.append(ui.human_bytes(total_filesize))
|
||||
|
||||
return ', '.join(summary_parts)
|
||||
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ def submit_items(log, userkey, items, chunksize=64):
|
|||
del data[:]
|
||||
|
||||
for item in items:
|
||||
fp = fingerprint_item(item)
|
||||
fp = fingerprint_item(log, item)
|
||||
|
||||
# Construct a submission dictionary for this item.
|
||||
item_data = {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,35 @@ def contains_feat(title):
|
|||
return bool(re.search(plugins.feat_tokens(), title, flags=re.IGNORECASE))
|
||||
|
||||
|
||||
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
|
||||
|
||||
# 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] != '':
|
||||
# Extract the featured artist from the right-hand side.
|
||||
_, feat_part = split_on_feat(albumartist_split[-1])
|
||||
|
||||
# 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 feat_part
|
||||
|
||||
|
||||
class FtInTitlePlugin(plugins.BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(FtInTitlePlugin, self).__init__()
|
||||
|
|
@ -125,27 +154,11 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
|
|||
_, featured = split_on_feat(artist)
|
||||
if featured and albumartist != artist and albumartist:
|
||||
self._log.info(displayable_path(item.path))
|
||||
|
||||
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:
|
||||
self._log.info('album artist not present in artist')
|
||||
|
||||
# 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] != '':
|
||||
# Extract the featured artist from the right-hand side.
|
||||
_, feat_part = split_on_feat(albumartist_split[-1])
|
||||
|
||||
# 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 rhs:
|
||||
feat_part = lhs
|
||||
# Attempt to find the featured artist.
|
||||
feat_part = find_feat_part(artist, albumartist)
|
||||
|
||||
# If we have a featuring artist, move it to the title.
|
||||
if feat_part:
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ Fixes:
|
|||
like the bitrate. :bug:`1268`
|
||||
* The error message when MusicBrainz is not reachable on the network is now
|
||||
much clearer. Thanks to Tom Jaspers. :bug:`1190` :bug:`1272`
|
||||
* Improve error messages when parsing query strings with shlex. :bug:`1290`
|
||||
|
||||
For developers:
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,62 @@ class FtInTitlePluginTest(unittest.TestCase):
|
|||
"""Set up configuration"""
|
||||
ftintitle.FtInTitlePlugin()
|
||||
|
||||
def test_find_feat_part(self):
|
||||
test_cases = [
|
||||
{
|
||||
'artist': 'Alice ft. Bob',
|
||||
'album_artist': 'Alice',
|
||||
'feat_part': 'Bob'
|
||||
},
|
||||
{
|
||||
'artist': 'Alice feat Bob',
|
||||
'album_artist': 'Alice',
|
||||
'feat_part': 'Bob'
|
||||
},
|
||||
{
|
||||
'artist': 'Alice featuring Bob',
|
||||
'album_artist': 'Alice',
|
||||
'feat_part': 'Bob'
|
||||
},
|
||||
{
|
||||
'artist': 'Alice & Bob',
|
||||
'album_artist': 'Alice',
|
||||
'feat_part': 'Bob'
|
||||
},
|
||||
{
|
||||
'artist': 'Alice and Bob',
|
||||
'album_artist': 'Alice',
|
||||
'feat_part': 'Bob'
|
||||
},
|
||||
{
|
||||
'artist': 'Alice With Bob',
|
||||
'album_artist': 'Alice',
|
||||
'feat_part': 'Bob'
|
||||
},
|
||||
{
|
||||
'artist': 'Alice defeat Bob',
|
||||
'album_artist': 'Alice',
|
||||
'feat_part': None
|
||||
},
|
||||
{
|
||||
'artist': 'Alice & Bob',
|
||||
'album_artist': 'Bob',
|
||||
'feat_part': 'Alice'
|
||||
},
|
||||
{
|
||||
'artist': 'Alice ft. Bob',
|
||||
'album_artist': 'Bob',
|
||||
'feat_part': 'Alice'
|
||||
},
|
||||
]
|
||||
|
||||
for test_case in test_cases:
|
||||
feat_part = ftintitle.find_feat_part(
|
||||
test_case['artist'],
|
||||
test_case['album_artist']
|
||||
)
|
||||
self.assertEqual(feat_part, test_case['feat_part'])
|
||||
|
||||
def test_split_on_feat(self):
|
||||
parts = ftintitle.split_on_feat('Alice ft. Bob')
|
||||
self.assertEqual(parts, ('Alice', 'Bob'))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is part of beets.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
|
|
@ -22,6 +21,8 @@ import os
|
|||
import re
|
||||
import shutil
|
||||
import StringIO
|
||||
import unicodedata
|
||||
import sys
|
||||
from tempfile import mkstemp
|
||||
from zipfile import ZipFile
|
||||
from tarfile import TarFile
|
||||
|
|
@ -1233,8 +1234,8 @@ class TagLogTest(_common.TestCase):
|
|||
sio = StringIO.StringIO()
|
||||
handler = logging.StreamHandler(sio)
|
||||
session = _common.import_session(loghandler=handler)
|
||||
session.tag_log('status', u'café') # send unicode
|
||||
self.assertIn(u'status café', sio.getvalue())
|
||||
session.tag_log('status', u'caf\xe9') # send unicode
|
||||
self.assertIn(u'status caf\xe9', sio.getvalue())
|
||||
|
||||
|
||||
class ResumeImportTest(unittest.TestCase, TestHelper):
|
||||
|
|
@ -1380,49 +1381,77 @@ class AlbumsInDirTest(_common.TestCase):
|
|||
|
||||
|
||||
class MultiDiscAlbumsInDirTest(_common.TestCase):
|
||||
def setUp(self):
|
||||
super(MultiDiscAlbumsInDirTest, self).setUp()
|
||||
def create_music(self, files=True, ascii=True):
|
||||
"""Create some music in multiple album directories.
|
||||
|
||||
self.base = os.path.abspath(os.path.join(self.temp_dir, 'tempdir'))
|
||||
`files` indicates whether to create the files (otherwise, only
|
||||
directories are made). `ascii` indicates ACII-only filenames;
|
||||
otherwise, we use Unicode names.
|
||||
"""
|
||||
self.base = os.path.abspath(os.path.join(self.temp_dir, b'tempdir'))
|
||||
os.mkdir(self.base)
|
||||
|
||||
name = b'CAT' if ascii else u'C\xc1T'.encode('utf8')
|
||||
name_alt_case = b'CAt' if ascii else u'C\xc1t'.encode('utf8')
|
||||
|
||||
self.dirs = [
|
||||
# Nested album, multiple subdirs.
|
||||
# Also, false positive marker in root dir, and subtitle for disc 3.
|
||||
os.path.join(self.base, 'ABCD1234'),
|
||||
os.path.join(self.base, 'ABCD1234', 'cd 1'),
|
||||
os.path.join(self.base, 'ABCD1234', 'cd 3 - bonus'),
|
||||
os.path.join(self.base, b'ABCD1234'),
|
||||
os.path.join(self.base, b'ABCD1234', b'cd 1'),
|
||||
os.path.join(self.base, b'ABCD1234', b'cd 3 - bonus'),
|
||||
|
||||
# Nested album, single subdir.
|
||||
# Also, punctuation between marker and disc number.
|
||||
os.path.join(self.base, 'album'),
|
||||
os.path.join(self.base, 'album', 'cd _ 1'),
|
||||
os.path.join(self.base, b'album'),
|
||||
os.path.join(self.base, b'album', b'cd _ 1'),
|
||||
|
||||
# Flattened album, case typo.
|
||||
# Also, false positive marker in parent dir.
|
||||
os.path.join(self.base, 'artist [CD5]'),
|
||||
os.path.join(self.base, 'artist [CD5]', 'CAT disc 1'),
|
||||
os.path.join(self.base, 'artist [CD5]', 'CAt disc 2'),
|
||||
os.path.join(self.base, b'artist [CD5]'),
|
||||
os.path.join(self.base, b'artist [CD5]', name + b' disc 1'),
|
||||
os.path.join(self.base, b'artist [CD5]',
|
||||
name_alt_case + b' disc 2'),
|
||||
|
||||
# Single disc album, sorted between CAT discs.
|
||||
os.path.join(self.base, 'artist [CD5]', 'CATS'),
|
||||
os.path.join(self.base, b'artist [CD5]', name + b'S'),
|
||||
]
|
||||
self.files = [
|
||||
os.path.join(self.base, 'ABCD1234', 'cd 1', 'song1.mp3'),
|
||||
os.path.join(self.base, 'ABCD1234', 'cd 3 - bonus', 'song2.mp3'),
|
||||
os.path.join(self.base, 'ABCD1234', 'cd 3 - bonus', 'song3.mp3'),
|
||||
os.path.join(self.base, 'album', 'cd _ 1', 'song4.mp3'),
|
||||
os.path.join(self.base, 'artist [CD5]', 'CAT disc 1', 'song5.mp3'),
|
||||
os.path.join(self.base, 'artist [CD5]', 'CAt disc 2', 'song6.mp3'),
|
||||
os.path.join(self.base, 'artist [CD5]', 'CATS', 'song7.mp3'),
|
||||
os.path.join(self.base, b'ABCD1234', b'cd 1', b'song1.mp3'),
|
||||
os.path.join(self.base, b'ABCD1234',
|
||||
b'cd 3 - bonus', b'song2.mp3'),
|
||||
os.path.join(self.base, b'ABCD1234',
|
||||
b'cd 3 - bonus', b'song3.mp3'),
|
||||
os.path.join(self.base, b'album', b'cd _ 1', b'song4.mp3'),
|
||||
os.path.join(self.base, b'artist [CD5]', name + b' disc 1',
|
||||
b'song5.mp3'),
|
||||
os.path.join(self.base, b'artist [CD5]',
|
||||
name_alt_case + b' disc 2', b'song6.mp3'),
|
||||
os.path.join(self.base, b'artist [CD5]', name + b'S',
|
||||
b'song7.mp3'),
|
||||
]
|
||||
|
||||
if not ascii:
|
||||
self.dirs = [self._normalize_path(p) for p in self.dirs]
|
||||
self.files = [self._normalize_path(p) for p in self.files]
|
||||
|
||||
for path in self.dirs:
|
||||
os.mkdir(path)
|
||||
for path in self.files:
|
||||
_mkmp3(path)
|
||||
if files:
|
||||
for path in self.files:
|
||||
_mkmp3(path)
|
||||
|
||||
def _normalize_path(self, path):
|
||||
"""Normalize a path's Unicode combining form according to the
|
||||
platform.
|
||||
"""
|
||||
path = path.decode('utf8')
|
||||
norm_form = 'NFD' if sys.platform == 'darwin' else 'NFC'
|
||||
path = unicodedata.normalize(norm_form, path)
|
||||
return path.encode('utf8')
|
||||
|
||||
def test_coalesce_nested_album_multiple_subdirs(self):
|
||||
self.create_music()
|
||||
albums = list(albums_in_dir(self.base))
|
||||
self.assertEquals(len(albums), 4)
|
||||
root, items = albums[0]
|
||||
|
|
@ -1430,30 +1459,46 @@ class MultiDiscAlbumsInDirTest(_common.TestCase):
|
|||
self.assertEquals(len(items), 3)
|
||||
|
||||
def test_coalesce_nested_album_single_subdir(self):
|
||||
self.create_music()
|
||||
albums = list(albums_in_dir(self.base))
|
||||
root, items = albums[1]
|
||||
self.assertEquals(root, self.dirs[3:5])
|
||||
self.assertEquals(len(items), 1)
|
||||
|
||||
def test_coalesce_flattened_album_case_typo(self):
|
||||
self.create_music()
|
||||
albums = list(albums_in_dir(self.base))
|
||||
root, items = albums[2]
|
||||
self.assertEquals(root, self.dirs[6:8])
|
||||
self.assertEquals(len(items), 2)
|
||||
|
||||
def test_single_disc_album(self):
|
||||
self.create_music()
|
||||
albums = list(albums_in_dir(self.base))
|
||||
root, items = albums[3]
|
||||
self.assertEquals(root, self.dirs[8:])
|
||||
self.assertEquals(len(items), 1)
|
||||
|
||||
def test_do_not_yield_empty_album(self):
|
||||
# Remove all the MP3s.
|
||||
for path in self.files:
|
||||
os.remove(path)
|
||||
self.create_music(files=False)
|
||||
albums = list(albums_in_dir(self.base))
|
||||
self.assertEquals(len(albums), 0)
|
||||
|
||||
def test_single_disc_unicode(self):
|
||||
self.create_music(ascii=False)
|
||||
albums = list(albums_in_dir(self.base))
|
||||
root, items = albums[3]
|
||||
self.assertEquals(root, self.dirs[8:])
|
||||
self.assertEquals(len(items), 1)
|
||||
|
||||
def test_coalesce_multiple_unicode(self):
|
||||
self.create_music(ascii=False)
|
||||
albums = list(albums_in_dir(self.base))
|
||||
self.assertEquals(len(albums), 4)
|
||||
root, items = albums[0]
|
||||
self.assertEquals(root, self.dirs[0:3])
|
||||
self.assertEquals(len(items), 3)
|
||||
|
||||
|
||||
class ReimportTest(unittest.TestCase, ImportHelper):
|
||||
"""Test "re-imports", in which the autotagging machinery is used for
|
||||
|
|
|
|||
|
|
@ -334,6 +334,9 @@ class MatchTest(_common.TestCase):
|
|||
q = dbcore.query.NumericQuery('bitrate', '200000..300000')
|
||||
self.assertFalse(q.match(self.item))
|
||||
|
||||
def test_open_range(self):
|
||||
dbcore.query.NumericQuery('bitrate', '100000..')
|
||||
|
||||
|
||||
class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin):
|
||||
def setUp(self):
|
||||
|
|
|
|||
Loading…
Reference in a new issue