Merge branch 'master' into play_opt_arg,

to make AppVeyor-builds possible.
This commit is contained in:
Oliver Rümpelein 2015-08-02 20:51:26 +02:00
commit d05e251a14
23 changed files with 260 additions and 77 deletions

View file

@ -271,6 +271,13 @@ class Model(object):
else:
return base_keys
@classmethod
def all_keys(self):
"""Get a list of available keys for objects of this type.
Includes fixed and computed fields.
"""
return list(self._fields) + self._getters().keys()
# Act like a dictionary.
def update(self, values):

View file

@ -766,7 +766,10 @@ class FixedFieldSort(FieldSort):
def order_clause(self):
order = "ASC" if self.ascending else "DESC"
if self.case_insensitive:
field = 'LOWER({})'.format(self.field)
field = '(CASE ' \
'WHEN TYPEOF({0})="text" THEN LOWER({0}) ' \
'WHEN TYPEOF({0})="blob" THEN LOWER({0}) ' \
'ELSE {0} END)'.format(self.field)
else:
field = self.field
return "{0} {1}".format(field, order)

View file

@ -1408,7 +1408,8 @@ def group_albums(session):
if task.skip:
continue
tasks = []
for _, items in itertools.groupby(task.items, group):
sorted_items = sorted(task.items, key=group)
for _, items in itertools.groupby(sorted_items, group):
items = list(items)
task = ImportTask(task.toppath, [i.path for i in items],
items)

View file

@ -479,15 +479,6 @@ class Item(LibModel):
i.mtime = i.current_mtime() # Initial mtime.
return i
@classmethod
def get_fields(cls):
"""Returns Item fields available for queries and format strings."""
plugin_fields = []
for plugin in plugins.find_plugins():
plugin_fields += plugin.template_fields.keys()
return (cls._fields.keys() + cls._getters().keys() +
cls._types.keys()), plugin_fields
def __setitem__(self, key, value):
"""Set the item's value for a standard field or a flexattr.
"""
@ -790,26 +781,21 @@ class Item(LibModel):
if beets.config['asciify_paths']:
subpath = unidecode(subpath)
# Truncate components and remove forbidden characters.
subpath = util.sanitize_path(subpath, self._db.replacements)
# Encode for the filesystem.
if not fragment:
subpath = bytestring_path(subpath)
# Preserve extension.
_, extension = os.path.splitext(self.path)
if fragment:
# Outputting Unicode.
extension = extension.decode('utf8', 'ignore')
subpath += extension.lower()
# Truncate too-long components.
maxlen = beets.config['max_filename_length'].get(int)
if not maxlen:
# When zero, try to determine from filesystem.
maxlen = util.max_filename_length(self._db.directory)
subpath = util.truncate_path(subpath, maxlen)
subpath, fellback = util.legalize_path(
subpath, self._db.replacements, maxlen,
os.path.splitext(self.path)[1], fragment
)
if fellback:
# Print an error message if legalization fell back to
# default replacements because of the maximum length.
log.warning('Fell back to default replacements when naming '
'file {}. Configure replacements to avoid lengthening '
'the filename.', subpath)
if fragment:
return subpath
@ -915,15 +901,6 @@ class Album(LibModel):
getters['albumtotal'] = Album._albumtotal
return getters
@classmethod
def get_fields(cls):
"""Returns Album fields available for queries and format strings."""
plugin_fields = []
for plugin in plugins.find_plugins():
plugin_fields += plugin.album_template_fields.keys()
return (cls._fields.keys() + cls._getters().keys() +
cls._types.keys()), plugin_fields
def items(self):
"""Returns an iterable over the items associated with this
album.

View file

@ -270,10 +270,23 @@ def _sc_encode(gain, peak):
# Cover art and other images.
def _wider_test_jpeg(data):
"""Test for a jpeg file following the UNIX file implementation which
uses the magic bytes rather than just looking for the bytes b'JFIF'
or b'EXIF' at a fixed position.
"""
if data[:2] == b'\xff\xd8':
return 'jpeg'
def _image_mime_type(data):
"""Return the MIME type of the image data (a bytestring).
"""
kind = imghdr.what(None, h=data)
# This checks for a jpeg file with only the magic bytes (unrecognized by
# imghdr.what). imghdr.what returns none for that type of file, so
# _wider_test_jpeg is run in that case. It still returns None if it didn't
# match such a jpeg file.
kind = imghdr.what(None, h=data) or _wider_test_jpeg(data)
if kind in ['gif', 'jpeg', 'png', 'tiff', 'bmp']:
return 'image/{0}'.format(kind)
elif kind == 'pgm':

View file

@ -82,17 +82,11 @@ def fields_func(lib, opts, args):
names.sort()
print_(" " + "\n ".join(names))
fs, pfs = library.Item.get_fields()
print_("Item fields:")
_print_rows(fs)
print_("Template fields from plugins:")
_print_rows(pfs)
_print_rows(library.Item.all_keys())
fs, pfs = library.Album.get_fields()
print_("Album fields:")
_print_rows(fs)
print_("Template fields from plugins:")
_print_rows(pfs)
_print_rows(library.Album.all_keys())
fields_cmd = ui.Subcommand(
@ -1482,11 +1476,13 @@ def config_func(lib, opts, args):
def config_edit():
"""Open a program to edit the user configuration.
An empty config file is created if no existing config file exists.
"""
path = config.user_config_path()
editor = os.environ.get('EDITOR')
try:
if not os.path.isfile(path):
open(path, 'w+').close()
util.interactive_open(path, editor)
except OSError as exc:
message = "Could not edit configuration: {0}".format(exc)

View file

@ -545,6 +545,78 @@ def truncate_path(path, length=MAX_FILENAME_LENGTH):
return os.path.join(*out)
def _legalize_stage(path, replacements, length, extension, fragment):
"""Perform a single round of path legalization steps
(sanitation/replacement, encoding from Unicode to bytes,
extension-appending, and truncation). Return the path (Unicode if
`fragment` is set, `bytes` otherwise) and whether truncation was
required.
"""
# Perform an initial sanitization including user replacements.
path = sanitize_path(path, replacements)
# Encode for the filesystem.
if not fragment:
path = bytestring_path(path)
# Preserve extension.
path += extension.lower()
# Truncate too-long components.
pre_truncate_path = path
path = truncate_path(path, length)
return path, path != pre_truncate_path
def legalize_path(path, replacements, length, extension, fragment):
"""Given a path-like Unicode string, produce a legal path. Return
the path and a flag indicating whether some replacements had to be
ignored (see below).
The legalization process (see `_legalize_stage`) consists of
applying the sanitation rules in `replacements`, encoding the string
to bytes (unless `fragment` is set), truncating components to
`length`, appending the `extension`.
This function performs up to three calls to `_legalize_stage` in
case truncation conflicts with replacements (as can happen when
truncation creates whitespace at the end of the string, for
example). The limited number of iterations iterations avoids the
possibility of an infinite loop of sanitation and truncation
operations, which could be caused by replacement rules that make the
string longer. The flag returned from this function indicates that
the path has to be truncated twice (indicating that replacements
made the string longer again after it was truncated); the
application should probably log some sort of warning.
"""
if fragment:
# Outputting Unicode.
extension = extension.decode('utf8', 'ignore')
first_stage_path, _ = _legalize_stage(
path, replacements, length, extension, fragment
)
# Convert back to Unicode with extension removed.
first_stage_path, _ = os.path.splitext(displayable_path(first_stage_path))
# Re-sanitize following truncation (including user replacements).
second_stage_path, retruncated = _legalize_stage(
first_stage_path, replacements, length, extension, fragment
)
# If the path was once again truncated, discard user replacements
# and run through one last legalization stage.
if retruncated:
second_stage_path, _ = _legalize_stage(
first_stage_path, None, length, extension, fragment
)
return second_stage_path, retruncated
def str2bool(value):
"""Returns a boolean reflecting a human-entered string."""
return value.lower() in ('yes', '1', 'true', 't', 'y')
@ -588,8 +660,8 @@ def cpu_count():
num = 0
elif sys.platform == b'darwin':
try:
num = int(command_output([b'sysctl', b'-n', b'hw.ncpu']))
except ValueError:
num = int(command_output([b'/usr/sbin/sysctl', b'-n', b'hw.ncpu']))
except (ValueError, OSError, subprocess.CalledProcessError):
num = 0
else:
try:

View file

@ -212,8 +212,8 @@ class DuplicatesPlugin(BeetsPlugin):
return key, checksum
def _group_by(self, objs, keys, strict):
"""Return a dictionary with keys arbitrary concatenations of attributes and
values lists of objects (Albums or Items) with those keys.
"""Return a dictionary with keys arbitrary concatenations of attributes
and values lists of objects (Albums or Items) with those keys.
If strict, all attributes must be defined for a duplicate match.
"""
@ -237,9 +237,13 @@ class DuplicatesPlugin(BeetsPlugin):
return counts
def _order(self, objs, tiebreak=None):
"""Return objs sorted by descending order of fields in tiebreak dict.
"""Return the objects (Items or Albums) sorted by descending
order of priority.
Default ordering is based on attribute completeness.
If provided, the `tiebreak` dict indicates the field to use to
prioritize the objects. Otherwise, Items are placed in order of
"completeness" (objects with more non-null fields come first)
and Albums are ordered by their track count.
"""
if tiebreak:
kind = 'items' if all(isinstance(o, Item)
@ -248,9 +252,14 @@ class DuplicatesPlugin(BeetsPlugin):
else:
kind = Item if all(isinstance(o, Item) for o in objs) else Album
if kind is Item:
fields = [f for sublist in kind.get_fields() for f in sublist]
key = lambda x: len([(a, getattr(x, a, None)) for a in fields
if getattr(x, a, None) not in (None, '')])
def truthy(v):
# Avoid a Unicode warning by avoiding comparison
# between a bytes object and the empty Unicode
# string ''.
return v is not None and \
(v != '' if isinstance(v, unicode) else True)
fields = kind.all_keys()
key = lambda x: sum(1 for f in fields if truthy(getattr(x, f)))
else:
key = lambda x: len(x.items())

View file

@ -436,7 +436,9 @@ class FetchArtPlugin(plugins.BeetsPlugin):
self._log.debug(u'downloaded art to: {0}',
util.displayable_path(fh.name))
return fh.name
except (IOError, requests.RequestException):
except (IOError, requests.RequestException, TypeError):
# Handling TypeError works around a urllib3 bug:
# https://github.com/shazow/urllib3/issues/556
self._log.debug(u'error fetching art')
def _is_valid_image_candidate(self, candidate):

View file

@ -388,6 +388,12 @@ class GStreamerBackend(Backend):
self._res = self.Gst.ElementFactory.make("audioresample", "res")
self._rg = self.Gst.ElementFactory.make("rganalysis", "rg")
if self._src is None or self._decbin is None or self._conv is None \
or self._res is None or self._rg is None:
raise FatalReplayGainError(
"Failed to load required GStreamer plugins"
)
# We check which files need gain ourselves, so all files given
# to rganalsys should have their gain computed, even if it
# already exists.

View file

@ -41,6 +41,7 @@ class ZeroPlugin(BeetsPlugin):
self.config.add({
'fields': [],
'update_database': False,
})
self.patterns = {}
@ -99,3 +100,5 @@ class ZeroPlugin(BeetsPlugin):
if match:
self._log.debug(u'{0}: {1} -> None', field, value)
tags[field] = None
if self.config['update_database']:
item[field] = None

View file

@ -26,9 +26,13 @@ New features:
:bug:`1104` :bug:`1493`
* :doc:`/plugins/plexupdate`: A new ``token`` configuration option lets you
specify a key for Plex Home setups. Thanks to :user:`edcarroll`. :bug:`1494`
* :doc:`/plugins/zero`: A new ``update_database`` configuration option
allows the database to be updated along with files' tags. :bug:`1516`
* :doc:`/plugins/play`: A new option `--args`/`-A` has been added, used to
hand over options to the player.
>>>>>>> master
Fixes:
* :doc:`/plugins/importfeeds`: Avoid generating incorrect m3u filename when
@ -66,6 +70,26 @@ Fixes:
:user:`Somasis`. :bug:`1512`
* Some messages in the console UI now use plural nouns correctly. Thanks to
:user:`JesseWeinstein`. :bug:`1521`
* Sorting numerical fields (such as track) now works again. :bug:`1511`
* :doc:`/plugins/replaygain`: Missing GStreamer plugins now cause a helpful
error message instead of a crash. :bug:`1518`
* Fix an edge case when producing sanitized filenames where the maximum path
length conflicted with the :ref:`replace` rules. Thanks to Ben Ockmore.
:bug:`496` :bug:`1361`
* Fix an incompatibility with OS X 10.11 (where ``/usr/sbin`` seems not to be
on the user's path by default).
* Fix an incompatibility with certain JPEG files. Here's a relevant `Python
bug`_. Thanks to :user:`nathdwek`. :bug:`1545`
* Fix the :ref:`group_albums` importer mode so it can handle when files are
not already in order by album. :bug:`1550`
* The ``fields`` command no longer separates built-in fields from
plugin-provided ones. This distinction was becoming increasingly unreliable.
* :doc:`/plugins/duplicates`: Fix a Unicode warning when paths contained
non-ASCII characters. :bug:`1551`
* :doc:`/plugins/fetchart`: Work around a urllib3 bug that could cause a
crash. :bug:`1555` :bug:`1556`
.. _Python bug: http://bugs.python.org/issue16512
1.3.13 (April 24, 2015)

View file

@ -361,11 +361,11 @@ method.
Here's an example plugin that provides a meaningless new field "foo"::
class fooplugin(beetsplugin):
class FooPlugin(BeetsPlugin):
def __init__(self):
field = mediafile.mediafield(
mediafile.mp3descstoragestyle(u'foo')
mediafile.storagestyle(u'foo')
field = mediafile.MediaField(
mediafile.MP3DescStorageStyle(u'foo'),
mediafile.StorageStyle(u'foo')
)
self.add_media_field('foo', field)

View file

@ -49,10 +49,10 @@ configuration file. The available options are:
- **google_engine_ID**: The custom search engine to use.
Default: The `beets custom search engine`_, which gathers an updated list of
sources known to be scrapeable.
- **sources**: List of sources to search for lyrics. An asterisk `*` expands
- **sources**: List of sources to search for lyrics. An asterisk ``*`` expands
to all available sources.
Default: ``google lyricwiki lyrics.com musixmatch``, i.e., all sources.
*google* source will be automatically deactivated if no `google_engine_ID` is
*google* source will be automatically deactivated if no ``google_API_key`` is
setup.
Here's an example of ``config.yaml``::

View file

@ -3,8 +3,7 @@ Zero Plugin
The ``zero`` plugin allows you to null fields in files' metadata tags. Fields
can be nulled unconditionally or conditioned on a pattern match. For example,
the plugin can strip useless comments like "ripped by MyGreatRipper." This
plugin only affects files' tags ; the beets database is left unchanged.
the plugin can strip useless comments like "ripped by MyGreatRipper."
To use the ``zero`` plugin, enable the plugin in your configuration
(see :ref:`using-plugins`).
@ -21,6 +20,8 @@ fields to nullify and the conditions for nullifying them:
embedded in the media file.
* To conditionally filter a field, use ``field: [regexp, regexp]`` to specify
regular expressions.
* By default this plugin only affects files' tags ; the beets database is left
unchanged. To update the tags in the database, set the ``update_database`` option.
For example::
@ -28,6 +29,7 @@ For example::
fields: month day genre comments
comments: [EAC, LAME, from.+collection, 'ripped by']
genre: [rnb, 'power metal']
update_database: true
If a custom pattern is not defined for a given field, the field will be nulled
unconditionally.

View file

@ -119,6 +119,11 @@ compatibility with Windows-influenced network filesystems like Samba).
Trailing dots and trailing whitespace, which can cause problems on Windows
clients, are also removed.
When replacements other than the defaults are used, it is possible that they
will increase the length of the path. In the scenario where this leads to a
conflict with the maximum filename length, the default replacements will be
used to resolve the conflict and beets will display a warning.
Note that paths might contain special characters such as typographical
quotes (``“”``). With the configuration above, those will not be
replaced as they don't match the typewriter quote (``"``). To also strip these

View file

@ -18,6 +18,10 @@ function _beet_field_values()
{
local -a output fieldvals
local library="$(beet config|grep library|cut -f 2 -d ' ')"
if [ -z "$library" ]; then
# Use default library location if there is no user defined one
library="~/.config/beets/library.db"
fi
output=$(sqlite3 ${~library} "select distinct $1 from items;")
case $1
in
@ -50,7 +54,7 @@ query=( \( "$matchquery" ":query:query string:{_beet_query}" \) \( "$matchquery"
modify=( \( "$matchmodify" ":modify:modify string:$modifyelem" \) \( "$matchmodify" ":modify:modify string:$modifyelem" \) \# )
# arguments to _regex_arguments for completing files and directories
local -a files dirs
local -a files dirs
files=("$matchany" ':file:file:_files')
dirs=("$matchany" ':dir:directory:_dirs')
@ -82,7 +86,7 @@ retagopt='-L:retag items matching a query:${query[@]}'
skipopt='-i:skip already-imported directories'
noskipopt='-I:do not skip already-imported directories'
flatopt='--flat:import an entire tree as a single album'
groupopt='-g:group tracks in a folder into seperate albums'
groupopt='-g:group tracks in a folder into seperate albums'
editopt='-e:edit user configuration with $EDITOR'
defaultopt='-d:include the default configuration'
copynomoveopt='-c:copy instead of moving'
@ -184,7 +188,7 @@ do
options=( "${reply[@]}" \# "${query[@]}" )
;;
(stats)
_regex_words options "stats options" "$helpopt" "$exactopt"
_regex_words options "stats options" "$helpopt" "$exactopt"
options=( "${reply[@]}" \# "${query[@]}" )
;;
(update)
@ -200,9 +204,9 @@ do
;;
(help)
# The help subcommand is treated separately
continue
continue
;;
(*) # completions for plugin commands are generated using _beet_subcmd_options
(*) # completions for plugin commands are generated using _beet_subcmd_options
_beet_subcmd_options "$subcmd"
options=( \( "${reply[@]}" \# "${query[@]}" \) )
;;
@ -237,6 +241,6 @@ zstyle ":completion:${curcontext}:" tag-order '! options'
# Execute the completion function
_beet "$@"
# Local Variables:
# Local Variables:
# mode:shell-script
# End:

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 B

View file

@ -452,8 +452,7 @@ 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):
def test_legalize_path_one_for_one_replacement(self):
# Use a replacement that should always replace the last X in any
# path component with a Z.
self.lib.replacements = [
@ -466,7 +465,23 @@ class DestinationTest(_common.TestCase):
# The final path should reflect the replacement.
dest = self.i.destination()
self.assertTrue('XZ' in dest)
self.assertEqual(dest[-2:], 'XZ')
def test_legalize_path_one_for_many_replacement(self):
# Use a replacement that should always replace the last X in any
# path component with four Zs.
self.lib.replacements = [
(re.compile(r'X$'), u'ZZZZ'),
]
# 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 ignore the user replacement and create a path
# of the correct length, containing Xs.
dest = self.i.destination()
self.assertEqual(dest[-2:], 'XX')
class ItemFormattedMappingTest(_common.LibTestCase):

View file

@ -471,7 +471,7 @@ class MBLibraryTest(unittest.TestCase):
ai = list(mb.match_album('hello', 'there'))[0]
sp.assert_called_with(artist='hello', release='there', limit=5)
gp.assert_calledwith(mbid)
gp.assert_called_with(mbid, mock.ANY)
self.assertEqual(ai.tracks[0].title, 'foo')
self.assertEqual(ai.album, 'hi')

View file

@ -78,6 +78,17 @@ class EdgeTest(unittest.TestCase):
f = beets.mediafile.MediaFile(os.path.join(_common.RSRC, 'oldape.ape'))
self.assertEqual(f.bitrate, 0)
def test_only_magic_bytes_jpeg(self):
# Some jpeg files can only be recognized by their magic bytes and as
# such aren't recognized by imghdr. Ensure that this still works thanks
# to our own follow up mimetype detection based on
# https://github.com/file/file/blob/master/magic/Magdir/jpeg#L12
f = open(os.path.join(_common.RSRC, 'only-magic-bytes.jpg'), 'rb')
jpg_data = f.read()
self.assertEqual(
beets.mediafile._image_mime_type(jpg_data),
'image/jpeg')
class InvalidValueToleranceTest(unittest.TestCase):

View file

@ -34,21 +34,21 @@ class DummyDataTestCase(_common.TestCase):
albums = [_common.album() for _ in range(3)]
albums[0].album = "Album A"
albums[0].genre = "Rock"
albums[0].year = "2001"
albums[0].year = 2001
albums[0].flex1 = "Flex1-1"
albums[0].flex2 = "Flex2-A"
albums[0].albumartist = "Foo"
albums[0].albumartist_sort = None
albums[1].album = "Album B"
albums[1].genre = "Rock"
albums[1].year = "2001"
albums[1].year = 2001
albums[1].flex1 = "Flex1-2"
albums[1].flex2 = "Flex2-A"
albums[1].albumartist = "Bar"
albums[1].albumartist_sort = None
albums[2].album = "Album C"
albums[2].genre = "Jazz"
albums[2].year = "2005"
albums[2].year = 2005
albums[2].flex1 = "Flex1-1"
albums[2].flex2 = "Flex2-B"
albums[2].albumartist = "Baz"
@ -67,6 +67,7 @@ class DummyDataTestCase(_common.TestCase):
items[0].album_id = albums[0].id
items[0].artist_sort = None
items[0].path = "/path0.mp3"
items[0].track = 1
items[1].title = 'Baz qux'
items[1].artist = 'Two'
items[1].album = 'Baz'
@ -77,6 +78,7 @@ class DummyDataTestCase(_common.TestCase):
items[1].album_id = albums[0].id
items[1].artist_sort = None
items[1].path = "/patH1.mp3"
items[1].track = 2
items[2].title = 'Beets 4 eva'
items[2].artist = 'Three'
items[2].album = 'Foo'
@ -87,6 +89,7 @@ class DummyDataTestCase(_common.TestCase):
items[2].album_id = albums[1].id
items[2].artist_sort = None
items[2].path = "/paTH2.mp3"
items[2].track = 3
items[3].title = 'Beets 4 eva'
items[3].artist = 'Three'
items[3].album = 'Foo2'
@ -97,6 +100,7 @@ class DummyDataTestCase(_common.TestCase):
items[3].album_id = albums[2].id
items[3].artist_sort = None
items[3].path = "/PATH3.mp3"
items[3].track = 4
for item in items:
self.lib.add(item)
@ -399,6 +403,7 @@ class CaseSensitivityTest(DummyDataTestCase, _common.TestCase):
item.flex2 = "flex2-A"
item.album_id = album.id
item.artist_sort = None
item.track = 10
self.lib.add(item)
self.new_album = album
@ -451,6 +456,17 @@ class CaseSensitivityTest(DummyDataTestCase, _common.TestCase):
self.assertEqual(results[0].flex1, 'Flex1-0')
self.assertEqual(results[-1].flex1, 'flex1')
def test_case_sensitive_only_affects_text(self):
config['sort_case_insensitive'] = True
q = 'track+'
results = list(self.lib.items(q))
# If the numerical values were sorted as strings,
# then ['1', '10', '2'] would be valid.
print([r.track for r in results])
self.assertEqual(results[0].track, 1)
self.assertEqual(results[1].track, 2)
self.assertEqual(results[-1].track, 10)
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)

View file

@ -88,6 +88,23 @@ class ZeroPluginTest(unittest.TestCase, TestHelper):
self.assertEqual(item['year'], 2000)
self.assertIsNone(mediafile.year)
def test_change_database(self):
item = self.add_item_fixture(year=2000)
item.write()
mediafile = MediaFile(item.path)
self.assertEqual(2000, mediafile.year)
config['zero'] = {
'fields': ['year'],
'update_database': True,
}
self.load_plugins('zero')
item.write()
mediafile = MediaFile(item.path)
self.assertEqual(item['year'], 0)
self.assertIsNone(mediafile.year)
def test_album_art(self):
path = self.create_mediafile_fixture(images=['jpg'])
item = Item.from_path(path)