Merge branch 'master' into thumbnails

Conflicts:
	docs/changelog.rst
This commit is contained in:
Bruno Cauet 2015-02-09 16:08:29 +01:00
commit c6455c269f
41 changed files with 831 additions and 300 deletions

View file

@ -14,12 +14,12 @@
from __future__ import absolute_import, unicode_literals
__version__ = '1.3.11'
__author__ = 'Adrian Sampson <adrian@radbox.org>'
import beets.library
from beets.util import confit
__version__ = '1.3.11'
__author__ = 'Adrian Sampson <adrian@radbox.org>'
Library = beets.library.Library
config = confit.LazyConfig('beets', __name__)

View file

@ -41,6 +41,8 @@ class MusicBrainzAPIError(util.HumanReadableException):
"""
def __init__(self, reason, verb, query, tb=None):
self.query = query
if isinstance(reason, musicbrainzngs.WebServiceError):
reason = 'MusicBrainz not reachable'
super(MusicBrainzAPIError, self).__init__(reason, verb, tb)
def get_message(self):

View file

@ -41,7 +41,6 @@ max_filename_length: 0
plugins: []
pluginpath: []
threaded: yes
color: yes
timeout: 5.0
per_disc_numbering: no
verbose: no
@ -52,6 +51,15 @@ id3v23: no
ui:
terminal_width: 80
length_diff_thresh: 10.0
color: yes
colors:
text_success: green
text_warning: yellow
text_error: red
text_highlight: red
text_highlight_minor: lightgray
action_default: turquoise
action: blue
list_format_item: $artist - $album - $title
list_format_album: $albumartist - $album

View file

@ -23,5 +23,6 @@ from .types import Type
from .queryparse import query_from_strings
from .queryparse import sort_from_strings
from .queryparse import parse_sorted_query
from .query import InvalidQueryError
# flake8: noqa

View file

@ -24,11 +24,28 @@ from datetime import datetime, timedelta
class InvalidQueryError(ValueError):
"""Represent any kind of invalid query
The query should be a unicode string or a list, which will be space-joined.
"""
def __init__(self, query, explanation):
if isinstance(query, list):
query = " ".join(query)
message = "'{0}': {1}".format(query, explanation)
super(InvalidQueryError, self).__init__(message)
class InvalidQueryArgumentTypeError(TypeError):
"""Represent a query argument that could not be converted as expected.
It exists to be caught in upper stack levels so a meaningful (i.e. with the
query) InvalidQueryError can be raised.
"""
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)
super(InvalidQueryArgumentTypeError, self).__init__(message)
class Query(object):
@ -160,8 +177,9 @@ class RegexpQuery(StringFieldQuery):
self.pattern = re.compile(self.pattern)
except re.error as exc:
# Invalid regular expression.
raise InvalidQueryError(pattern, "a regular expression",
format(exc))
raise InvalidQueryArgumentTypeError(pattern,
"a regular expression",
format(exc))
@classmethod
def string_match(cls, pattern, value):
@ -214,17 +232,21 @@ 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:
try:
return float(s)
except ValueError:
raise InvalidQueryError(s, "an int or a float")
raise InvalidQueryArgumentTypeError(s, "an int or a float")
def __init__(self, field, pattern, fast=True):
super(NumericQuery, self).__init__(field, pattern, fast)

View file

@ -546,10 +546,19 @@ class ImportTask(object):
return
plugins.send('album_imported', lib=lib, album=self.album)
def emit_created(self, session):
"""Send the `import_task_created` event for this task.
def handle_created(self, session):
"""Send the `import_task_created` event for this task. Return a list of
tasks that should continue through the pipeline. By default, this is a
list containing only the task itself, but plugins can replace the task
with new ones.
"""
plugins.send('import_task_created', session=session, task=self)
tasks = plugins.send('import_task_created', session=session, task=self)
if not tasks:
tasks = [self]
else:
# The plugins gave us a list of lists of tasks. Flatten it.
tasks = [t for inner in tasks for t in inner]
return tasks
def lookup_candidates(self):
"""Retrieve and store candidates for this album.
@ -1006,14 +1015,14 @@ class ImportTaskFactory(object):
for dirs, paths in self.paths():
if self.session.config['singletons']:
for path in paths:
task = self._create(self.singleton(path))
if task:
tasks = self._create(self.singleton(path))
for task in tasks:
yield task
yield self.sentinel(dirs)
else:
task = self._create(self.album(paths, dirs))
if task:
tasks = self._create(self.album(paths, dirs))
for task in tasks:
yield task
# Produce the final sentinel for this toppath to indicate that
@ -1033,10 +1042,10 @@ class ImportTaskFactory(object):
task. If `task` is None, do nothing.
"""
if task:
task.emit_created(self.session)
if not task.skip:
self.imported += 1
return task
tasks = task.handle_created(self.session)
self.imported += len(tasks)
return tasks
return []
def paths(self):
"""Walk `self.toppath` and yield `(dirs, files)` pairs where
@ -1189,8 +1198,8 @@ def query_tasks(session):
# Search for items.
for item in session.lib.items(session.query):
task = SingletonImportTask(None, item)
task.emit_created(session)
yield task
for task in task.handle_created(session):
yield task
else:
# Search for albums.
@ -1206,8 +1215,8 @@ def query_tasks(session):
item.album_id = None
task = ImportTask(None, [album.item_dir()], items)
task.emit_created(session)
yield task
for task in task.handle_created(session):
yield task
@pipeline.mutator_stage
@ -1254,8 +1263,8 @@ def user_query(session, task):
def emitter(task):
for item in task.items:
task = SingletonImportTask(task.toppath, item)
task.emit_created(session)
yield task
for new_task in task.handle_created(session):
yield new_task
yield SentinelImportTask(task.toppath, task.paths)
ipl = pipeline.Pipeline([
@ -1365,9 +1374,6 @@ def manipulate_files(session, task):
def log_files(session, task):
"""A coroutine (pipeline stage) to log each file to be imported.
"""
if task.skip:
return
if isinstance(task, SingletonImportTask):
log.info(u'Singleton: {0}', displayable_path(task.item['path']))
elif task.items:
@ -1394,8 +1400,7 @@ def group_albums(session):
tasks = []
for _, items in itertools.groupby(task.items, group):
task = ImportTask(items=list(items))
task.emit_created(session)
tasks.append(task)
tasks += task.handle_created(session)
tasks.append(SentinelImportTask(task.toppath, task.paths))
task = pipeline.multiple(tasks)
@ -1457,7 +1462,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
@ -1479,7 +1484,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

View file

@ -59,7 +59,7 @@ class PathQuery(dbcore.FieldQuery):
return (item.path == self.file_path) or \
item.path.startswith(self.dir_path)
def clause(self):
def col_clause(self):
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'%')
@ -402,6 +402,14 @@ class Item(LibModel):
`write`.
"""
_media_tag_fields = set(MediaFile.fields()).intersection(_fields.keys())
"""Set of item fields that are backed by *writable* `MediaFile` tag
fields.
This excludes fields that represent audio data, such as `bitrate` or
`length`.
"""
_formatter = FormattedItemMapping
_sorts = {'artist': SmartArtistSort}
@ -412,6 +420,8 @@ class Item(LibModel):
def _getters(cls):
getters = plugins.item_field_getters()
getters['singleton'] = lambda i: i.album_id is None
# Filesize is given in bytes
getters['filesize'] = lambda i: os.path.getsize(syspath(i.path))
return getters
@classmethod
@ -492,12 +502,18 @@ class Item(LibModel):
self.path = read_path
def write(self, path=None):
def write(self, path=None, tags=None):
"""Write the item's metadata to a media file.
All fields in `_media_fields` are written to disk according to
the values on this object.
`path` is the path of the mediafile to wirte the data to. It
defaults to the item's path.
`tags` is a dictionary of additional metadata the should be
written to the file.
Can raise either a `ReadError` or a `WriteError`.
"""
if path is None:
@ -505,8 +521,10 @@ class Item(LibModel):
else:
path = normpath(path)
tags = dict(self)
plugins.send('write', item=self, path=path, tags=tags)
item_tags = dict(self)
if tags is not None:
item_tags.update(tags)
plugins.send('write', item=self, path=path, tags=item_tags)
try:
mediafile = MediaFile(syspath(path),
@ -514,7 +532,7 @@ class Item(LibModel):
except (OSError, IOError, UnreadableFileError) as exc:
raise ReadError(self.path, exc)
mediafile.update(tags)
mediafile.update(item_tags)
try:
mediafile.save()
except (OSError, IOError, MutagenError) as exc:
@ -525,14 +543,14 @@ class Item(LibModel):
self.mtime = self.current_mtime()
plugins.send('after_write', item=self, path=path)
def try_write(self, path=None):
def try_write(self, path=None, tags=None):
"""Calls `write()` but catches and logs `FileOperationError`
exceptions.
Returns `False` an exception was caught and `True` otherwise.
"""
try:
self.write(path)
self.write(path, tags)
return True
except FileOperationError as exc:
log.error("{0}", exc)
@ -776,6 +794,10 @@ class Album(LibModel):
_search_fields = ('album', 'albumartist', 'genre')
_types = {
'path': PathType(),
}
_sorts = {
'albumartist': SmartArtistSort,
'artist': SmartArtistSort,
@ -1031,26 +1053,25 @@ def parse_query_parts(parts, model_cls):
# Special-case path-like queries, which are non-field queries
# containing path separators (/).
if 'path' in model_cls._fields:
path_parts = []
non_path_parts = []
for s in parts:
if s.find(os.sep, 0, s.find(':')) != -1:
# Separator precedes colon.
path_parts.append(s)
else:
non_path_parts.append(s)
else:
path_parts = ()
non_path_parts = parts
path_parts = []
non_path_parts = []
for s in parts:
if s.find(os.sep, 0, s.find(':')) != -1:
# Separator precedes colon.
path_parts.append(s)
else:
non_path_parts.append(s)
query, sort = dbcore.parse_sorted_query(
model_cls, non_path_parts, prefixes
)
# Add path queries to aggregate query.
if path_parts:
query.subqueries += [PathQuery('path', s) for s in path_parts]
# Match field / flexattr depending on whether the model has the path field
fast_path_query = 'path' in model_cls._fields
query.subqueries += [PathQuery('path', s, fast_path_query)
for s in path_parts]
return query, sort
@ -1065,7 +1086,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 dbcore.InvalidQueryError(s, exc)
return parse_query_parts(parts, model_cls)
@ -1135,11 +1159,14 @@ class Library(dbcore.Database):
in the query string the `sort` argument is ignored.
"""
# Parse the query, if necessary.
parsed_sort = None
if isinstance(query, basestring):
query, parsed_sort = parse_query_string(query, model_cls)
elif isinstance(query, (list, tuple)):
query, parsed_sort = parse_query_parts(query, model_cls)
try:
parsed_sort = None
if isinstance(query, basestring):
query, parsed_sort = parse_query_string(query, model_cls)
elif isinstance(query, (list, tuple)):
query, parsed_sort = parse_query_parts(query, model_cls)
except dbcore.query.InvalidQueryArgumentTypeError as exc:
raise dbcore.InvalidQueryError(query, exc)
# Any non-null sort specified by the parsed query overrides the
# provided sort.

View file

@ -107,7 +107,7 @@ def print_(*strings):
if isinstance(strings[0], unicode):
txt = u' '.join(strings)
else:
txt = ' '.join(strings)
txt = b' '.join(strings)
else:
txt = u''
if isinstance(txt, unicode):
@ -196,7 +196,7 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
is_default = False
# Colorize the letter shortcut.
show_letter = colorize('turquoise' if is_default else 'blue',
show_letter = colorize('action_default' if is_default else 'action',
show_letter)
# Insert the highlighted letter back into the word.
@ -223,7 +223,7 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
if numrange:
if isinstance(default, int):
default_name = unicode(default)
default_name = colorize('turquoise', default_name)
default_name = colorize('action_default', default_name)
tmpl = '# selection (default %s)'
prompt_parts.append(tmpl % default_name)
prompt_part_lengths.append(len(tmpl % unicode(default)))
@ -362,6 +362,12 @@ LIGHT_COLORS = ["darkgray", "red", "green", "yellow", "blue",
"fuchsia", "turquoise", "white"]
RESET_COLOR = COLOR_ESCAPE + "39;49;00m"
# These abstract COLOR_NAMES are lazily mapped on to the actual color in COLORS
# as they are defined in the configuration files, see function: colorize
COLOR_NAMES = ['text_success', 'text_warning', 'text_error', 'text_highlight',
'text_highlight_minor', 'action_default', 'action']
COLORS = None
def _colorize(color, text):
"""Returns a string that prints the given text in the given color
@ -377,17 +383,28 @@ def _colorize(color, text):
return escape + text + RESET_COLOR
def colorize(color, text):
def colorize(color_name, text):
"""Colorize text if colored output is enabled. (Like _colorize but
conditional.)
"""
if config['color']:
if config['ui']['color']:
global COLORS
if not COLORS:
COLORS = dict((name, config['ui']['colors'][name].get(unicode))
for name in COLOR_NAMES)
# In case a 3rd party plugin is still passing the actual color ('red')
# instead of the abstract color name ('text_error')
color = COLORS.get(color_name)
if not color:
log.debug(u'Invalid color_name: {0}', color_name)
color = color_name
return _colorize(color, text)
else:
return text
def _colordiff(a, b, highlight='red', minor_highlight='lightgray'):
def _colordiff(a, b, highlight='text_highlight',
minor_highlight='text_highlight_minor'):
"""Given two values, return the same pair of strings except with
their differences highlighted in the specified color. Strings are
highlighted intelligently to show differences; other values are
@ -437,11 +454,11 @@ def _colordiff(a, b, highlight='red', minor_highlight='lightgray'):
return u''.join(a_out), u''.join(b_out)
def colordiff(a, b, highlight='red'):
def colordiff(a, b, highlight='text_highlight'):
"""Colorize differences between two values if color is enabled.
(Like _colordiff but conditional.)
"""
if config['color']:
if config['ui']['color']:
return _colordiff(a, b, highlight)
else:
return unicode(a), unicode(b)
@ -526,7 +543,8 @@ def _field_diff(field, old, new):
if isinstance(oldval, basestring):
oldstr, newstr = colordiff(oldval, newstr)
else:
oldstr, newstr = colorize('red', oldstr), colorize('red', newstr)
oldstr = colorize('text_error', oldstr)
newstr = colorize('text_error', newstr)
return u'{0} -> {1}'.format(oldstr, newstr)
@ -562,7 +580,7 @@ def show_model_changes(new, old=None, fields=None, always=False):
changes.append(u' {0}: {1}'.format(
field,
colorize('red', new.formatted()[field])
colorize('text_highlight', new.formatted()[field])
))
# Print changes.
@ -821,8 +839,8 @@ def _setup(options, lib=None):
if lib is None:
lib = _open_library(config)
plugins.send("library_opened", lib=lib)
library.Item._types = plugins.types(library.Item)
library.Album._types = plugins.types(library.Album)
library.Item._types.update(plugins.types(library.Item))
library.Album._types.update(plugins.types(library.Album))
return subcommands, plugins, lib
@ -845,6 +863,14 @@ def _configure(options):
else:
log.setLevel(logging.INFO)
# Ensure compatibility with old (top-level) color configuration.
# Deprecation msg to motivate user to switch to config['ui']['color].
if config['color'].exists():
log.warning(u'Warning: top-level configuration of `color` '
u'is deprecated. Configure color use under `ui`. '
u'See documentation for more info.')
config['ui']['color'].set(config['color'].get(bool))
config_path = config.user_config_path()
if os.path.isfile(config_path):
log.debug(u'user configuration: {0}',

View file

@ -176,11 +176,11 @@ def dist_string(dist):
"""
out = '%.1f%%' % ((1 - dist) * 100)
if dist <= config['match']['strong_rec_thresh'].as_number():
out = ui.colorize('green', out)
out = ui.colorize('text_success', out)
elif dist <= config['match']['medium_rec_thresh'].as_number():
out = ui.colorize('yellow', out)
out = ui.colorize('text_warning', out)
else:
out = ui.colorize('red', out)
out = ui.colorize('text_error', out)
return out
@ -197,7 +197,7 @@ def penalty_string(distance, limit=None):
if penalties:
if limit and len(penalties) > limit:
penalties = penalties[:limit] + ['...']
return ui.colorize('yellow', '(%s)' % ', '.join(penalties))
return ui.colorize('text_warning', '(%s)' % ', '.join(penalties))
def show_change(cur_artist, cur_album, match):
@ -270,7 +270,7 @@ def show_change(cur_artist, cur_album, match):
# Disambiguation.
disambig = disambig_string(match.info)
if disambig:
info.append(ui.colorize('lightgray', '(%s)' % disambig))
info.append(ui.colorize('text_highlight_minor', '(%s)' % disambig))
print_(' '.join(info))
# Tracks.
@ -315,9 +315,9 @@ def show_change(cur_artist, cur_album, match):
cur_track, new_track = format_index(item), format_index(track_info)
if cur_track != new_track:
if item.track in (track_info.index, track_info.medium_index):
color = 'lightgray'
color = 'text_highlight_minor'
else:
color = 'red'
color = 'text_highlight'
templ = ui.colorize(color, u' (#{0})')
lhs += templ.format(cur_track)
rhs += templ.format(new_track)
@ -329,7 +329,7 @@ def show_change(cur_artist, cur_album, match):
config['ui']['length_diff_thresh'].as_number():
cur_length = ui.human_seconds_short(item.length)
new_length = ui.human_seconds_short(track_info.length)
templ = ui.colorize('red', u' ({0})')
templ = ui.colorize('text_highlight', u' ({0})')
lhs += templ.format(cur_length)
rhs += templ.format(new_length)
lhs_width += len(cur_length) + 3
@ -359,19 +359,23 @@ def show_change(cur_artist, cur_album, match):
# Missing and unmatched tracks.
if match.extra_tracks:
print_('Missing tracks:')
print_('Missing tracks ({0}/{1} - {2:.1%}):'.format(
len(match.extra_tracks),
len(match.info.tracks),
len(match.extra_tracks) / len(match.info.tracks)
))
for track_info in match.extra_tracks:
line = ' ! %s (#%s)' % (track_info.title, format_index(track_info))
if track_info.length:
line += ' (%s)' % ui.human_seconds_short(track_info.length)
print_(ui.colorize('yellow', line))
print_(ui.colorize('text_warning', line))
if match.extra_items:
print_('Unmatched tracks:')
print_('Unmatched tracks ({0}):'.format(len(match.extra_items)))
for item in match.extra_items:
line = ' ! %s (#%s)' % (item.title, format_index(item))
if item.length:
line += ' (%s)' % ui.human_seconds_short(item.length)
print_(ui.colorize('yellow', line))
print_(ui.colorize('text_warning', line))
def show_item_change(item, match):
@ -408,7 +412,7 @@ def show_item_change(item, match):
# Disambiguation.
disambig = disambig_string(match.info)
if disambig:
info.append(ui.colorize('lightgray', '(%s)' % disambig))
info.append(ui.colorize('text_highlight_minor', '(%s)' % disambig))
print_(' '.join(info))
@ -439,8 +443,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)
@ -567,7 +573,8 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
# Disambiguation
disambig = disambig_string(match.info)
if disambig:
line.append(ui.colorize('lightgray', '(%s)' % disambig))
line.append(ui.colorize('text_highlight_minor',
'(%s)' % disambig))
print_(' '.join(line))
@ -997,7 +1004,7 @@ def update_items(lib, query, album, move, pretend):
# Item deleted?
if not os.path.exists(syspath(item.path)):
ui.print_(format(item))
ui.print_(ui.colorize('red', u' deleted'))
ui.print_(ui.colorize('text_error', u' deleted'))
if not pretend:
item.remove(True)
affected_albums.add(item.album_id)
@ -1428,7 +1435,7 @@ def write_items(lib, query, pretend, force):
# Check for and display changes.
changed = ui.show_model_changes(item, clean_item,
library.Item._media_fields, force)
library.Item._media_tag_fields, force)
if (changed or force) and not pretend:
item.try_sync()

View file

@ -480,7 +480,7 @@ def unique_path(path):
return path
base, ext = os.path.splitext(path)
match = re.search(r'\.(\d)+$', base)
match = re.search(br'\.(\d)+$', base)
if match:
num = int(match.group(1))
base = base[:match.start()]
@ -488,7 +488,7 @@ def unique_path(path):
num = 0
while True:
num += 1
new_path = '%s.%i%s' % (base, num, ext)
new_path = b'%s.%i%s' % (base, num, ext)
if not os.path.exists(new_path):
return new_path

View file

@ -29,7 +29,7 @@ import urllib
import pygst
pygst.require('0.10')
import gst
import gst # noqa
class GstPlayer(object):

View file

@ -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 = {

View file

@ -30,6 +30,8 @@ import beets
import re
import time
import json
import socket
import httplib
# Silence spurious INFO log lines generated by urllib3.
@ -38,6 +40,10 @@ urllib3_logger.setLevel(logging.CRITICAL)
USER_AGENT = u'beets/{0} +http://beets.radbox.org/'.format(beets.__version__)
# Exceptions that discogs_client should really handle but does not.
CONNECTION_ERRORS = (ConnectionError, socket.error, httplib.HTTPException,
ValueError) # JSON decoding raises a ValueError.
class DiscogsPlugin(BeetsPlugin):
@ -90,6 +96,9 @@ class DiscogsPlugin(BeetsPlugin):
token, secret = auth_client.get_access_token(code)
except DiscogsAPIError:
raise beets.ui.UserError('Discogs authorization failed')
except CONNECTION_ERRORS as e:
self._log.debug(u'connection error: {0}', e)
raise beets.ui.UserError('communication with Discogs failed')
# Save the token for later use.
self._log.debug('Discogs token {0}, secret {1}', token, secret)
@ -122,7 +131,7 @@ class DiscogsPlugin(BeetsPlugin):
except DiscogsAPIError as e:
self._log.debug(u'API Error: {0} (query: {1})', e, query)
return []
except ConnectionError as e:
except CONNECTION_ERRORS as e:
self._log.debug(u'HTTP Connection Error: {0}', e)
return []
@ -150,7 +159,7 @@ class DiscogsPlugin(BeetsPlugin):
if e.message != '404 Not Found':
self._log.debug(u'API Error: {0} (query: {1})', e, result._uri)
return None
except ConnectionError as e:
except CONNECTION_ERRORS as e:
self._log.debug(u'HTTP Connection Error: {0}', e)
return None
return self.get_album_info(result)

View file

@ -27,7 +27,7 @@ PLUGIN = 'duplicates'
def _process_item(item, lib, copy=False, move=False, delete=False,
tag=False, format=''):
tag=False, fmt=''):
"""Process Item `item` in `lib`.
"""
if copy:
@ -45,7 +45,7 @@ def _process_item(item, lib, copy=False, move=False, delete=False,
raise UserError('%s: can\'t parse k=v tag: %s' % (PLUGIN, tag))
setattr(k, v)
item.store()
print_(format(item, format))
print_(format(item, fmt))
def _checksum(item, prog, log):
@ -229,7 +229,7 @@ class DuplicatesPlugin(BeetsPlugin):
move=move,
delete=delete,
tag=tag,
format=fmt.format(obj_count))
fmt=fmt.format(obj_count))
self._command.func = _dup
return [self._command]

View file

@ -88,12 +88,30 @@ class EmbedCoverArtPlugin(BeetsPlugin):
help='extract an image from file metadata')
extract_cmd.parser.add_option('-o', dest='outpath',
help='image output file')
extract_cmd.parser.add_option('-n', dest='filename',
help='image filename to create for all '
'matched albums')
extract_cmd.parser.add_option('-a', dest='associate',
action='store_true',
help='associate the extracted images '
'with the album')
def extract_func(lib, opts, args):
outpath = normpath(opts.outpath or config['art_filename'].get())
for item in lib.items(decargs(args)):
if self.extract(outpath, item):
if opts.outpath:
self.extract_first(normpath(opts.outpath),
lib.items(decargs(args)))
else:
filename = opts.filename or config['art_filename'].get()
if os.path.dirname(filename) != '':
self._log.error(u"Only specify a name rather than a path "
u"for -n")
return
for album in lib.albums(decargs(args)):
artpath = normpath(os.path.join(album.path, filename))
artpath = self.extract_first(artpath, album.items())
if artpath and opts.associate:
album.set_art(artpath)
album.store()
extract_cmd.func = extract_func
# Clear command.
@ -130,13 +148,11 @@ class EmbedCoverArtPlugin(BeetsPlugin):
try:
self._log.debug(u'embedding {0}', displayable_path(imagepath))
item['images'] = [self._mediafile_image(imagepath, maxwidth)]
image = self._mediafile_image(imagepath, maxwidth)
except IOError as exc:
self._log.warning(u'could not read image file: {0}', exc)
else:
# We don't want to store the image in the database.
item.try_write(itempath)
del item['images']
return
item.try_write(path=itempath, tags={'images': [image]})
def embed_album(self, album, maxwidth=None, quiet=False):
"""Embed album art into all of the album's items.
@ -236,7 +252,6 @@ class EmbedCoverArtPlugin(BeetsPlugin):
return mf.art
# 'extractart' command.
def extract(self, outpath, item):
art = self.get_art(item)
@ -258,6 +273,12 @@ class EmbedCoverArtPlugin(BeetsPlugin):
f.write(art)
return outpath
def extract_first(self, outpath, items):
for item in items:
real_path = self.extract(outpath, item)
if real_path:
return real_path
# 'clearart' command.
def clear(self, lib, query):
id3v23 = config['id3v23'].get(bool)

View file

@ -447,9 +447,9 @@ class FetchArtPlugin(plugins.BeetsPlugin):
if path:
album.set_art(path, False)
album.store()
message = ui.colorize('green', 'found album art')
message = ui.colorize('text_success', 'found album art')
else:
message = ui.colorize('red', 'no art found')
message = ui.colorize('text_error', 'no art found')
self._log.info(u'{0}: {1}', album, message)

View file

@ -18,7 +18,7 @@
import re
from beets import config
from beets.plugins import BeetsPlugin
from beets.importer import action, SingletonImportTask
from beets.importer import SingletonImportTask
class FileFilterPlugin(BeetsPlugin):
@ -50,10 +50,16 @@ class FileFilterPlugin(BeetsPlugin):
if len(items_to_import) > 0:
task.items = items_to_import
else:
task.choice_flag = action.SKIP
# Returning an empty list of tasks from the handler
# drops the task from the rest of the importer pipeline.
return []
elif isinstance(task, SingletonImportTask):
if not self.file_filter(task.item['path']):
task.choice_flag = action.SKIP
return []
# If not filtered, return the original task unchanged.
return [task]
def file_filter(self, full_path):
"""Checks if the configured regular expressions allow the import

View file

@ -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:

View file

@ -63,7 +63,7 @@ def _write_m3u(m3u_path, items_paths):
mkdirall(m3u_path)
with open(syspath(m3u_path), 'a') as f:
for path in items_paths:
f.write(path + '\n')
f.write(path + b'\n')
class ImportFeedsPlugin(BeetsPlugin):

View file

@ -19,6 +19,7 @@ from __future__ import (division, absolute_import, print_function,
unicode_literals)
import os
import re
from beets.plugins import BeetsPlugin
from beets import ui
@ -77,7 +78,7 @@ def update_summary(summary, tags):
def print_data(data):
path = data.pop('path')
path = data.pop('path', None)
formatted = {}
for key, value in data.iteritems():
if isinstance(value, list):
@ -85,6 +86,9 @@ def print_data(data):
if value is not None:
formatted[key] = value
if len(formatted) == 0:
return
maxwidth = max(len(key) for key in formatted)
lineformat = u'{{0:>{0}}}: {{1}}'.format(maxwidth)
@ -107,6 +111,9 @@ class InfoPlugin(BeetsPlugin):
help='show library fields instead of tags')
cmd.parser.add_option('-s', '--summarize', action='store_true',
help='summarize the tags of all files')
cmd.parser.add_option('-i', '--include-keys', default=[],
action='append', dest='included_keys',
help='comma separated list of keys to show')
return [cmd]
def run(self, lib, opts, args):
@ -128,6 +135,11 @@ class InfoPlugin(BeetsPlugin):
else:
data_collector = tag_data
included_keys = []
for keys in opts.included_keys:
included_keys.extend(keys.split(','))
key_filter = make_key_filter(included_keys)
first = True
summary = {}
for data_emitter in data_collector(lib, ui.decargs(args)):
@ -137,6 +149,9 @@ class InfoPlugin(BeetsPlugin):
self._log.error(u'cannot read file: {0}', ex)
continue
path = data.get('path')
data = key_filter(data)
data['path'] = path # always show path
if opts.summarize:
update_summary(summary, data)
else:
@ -147,3 +162,33 @@ class InfoPlugin(BeetsPlugin):
if opts.summarize:
print_data(summary)
def make_key_filter(include):
"""Return a function that filters a dictionary.
The returned filter takes a dictionary and returns another
dictionary that only includes the key-value pairs where the key
glob-matches one of the keys in `include`.
"""
if not include:
return identity
matchers = []
for key in include:
key = re.escape(key)
key = key.replace(r'\*', '.*')
matchers.append(re.compile(key + '$'))
def filter(data):
filtered = dict()
for key, value in data.items():
if any(map(lambda m: m.match(key), matchers)):
filtered[key] = value
return filtered
return filter
def identity(val):
return val

View file

@ -414,8 +414,9 @@ class Google(Backend):
def fetch(self, artist, title):
query = u"%s %s" % (artist, title)
url = u'https://www.googleapis.com/customsearch/v1?key=%s&cx=%s&q=%s' % \
(self.api_key, self.engine_id, urllib.quote(query.encode('utf8')))
url = u'https://www.googleapis.com/customsearch/v1?key=%s&cx=%s&q=%s' \
% (self.api_key, self.engine_id,
urllib.quote(query.encode('utf8')))
data = urllib.urlopen(url)
data = json.load(data)

View file

@ -76,13 +76,14 @@ def play_music(lib, opts, args, log):
item_type += 's' if len(selection) > 1 else ''
if not selection:
ui.print_(ui.colorize('yellow', 'No {0} to play.'.format(item_type)))
ui.print_(ui.colorize('text_warning',
'No {0} to play.'.format(item_type)))
return
# Warn user before playing any huge playlists.
if len(selection) > 100:
ui.print_(ui.colorize(
'yellow',
'text_warning',
'You are about to queue {0} {1}.'.format(len(selection), item_type)
))

View file

@ -26,21 +26,21 @@ from beets import config
from beets import mediafile
_MUTAGEN_FORMATS = {
'asf': 'ASF',
'apev2': 'APEv2File',
'flac': 'FLAC',
'id3': 'ID3FileType',
'mp3': 'MP3',
'mp4': 'MP4',
'oggflac': 'OggFLAC',
'oggspeex': 'OggSpeex',
'oggtheora': 'OggTheora',
'oggvorbis': 'OggVorbis',
'oggopus': 'OggOpus',
'trueaudio': 'TrueAudio',
'wavpack': 'WavPack',
'monkeysaudio': 'MonkeysAudio',
'optimfrog': 'OptimFROG',
b'asf': b'ASF',
b'apev2': b'APEv2File',
b'flac': b'FLAC',
b'id3': b'ID3FileType',
b'mp3': b'MP3',
b'mp4': b'MP4',
b'oggflac': b'OggFLAC',
b'oggspeex': b'OggSpeex',
b'oggtheora': b'OggTheora',
b'oggvorbis': b'OggVorbis',
b'oggopus': b'OggOpus',
b'trueaudio': b'TrueAudio',
b'wavpack': b'WavPack',
b'monkeysaudio': b'MonkeysAudio',
b'optimfrog': b'OptimFROG',
}
@ -70,8 +70,12 @@ class ScrubPlugin(BeetsPlugin):
# Get album art if we need to restore it.
if opts.write:
mf = mediafile.MediaFile(item.path,
config['id3v23'].get(bool))
try:
mf = mediafile.MediaFile(util.syspath(item.path),
config['id3v23'].get(bool))
except IOError as exc:
self._log.error(u'could not open file to scrub: {0}',
exc)
art = mf.art
# Remove all tags.
@ -83,7 +87,7 @@ class ScrubPlugin(BeetsPlugin):
item.try_write()
if art:
self._log.info(u'restoring art')
mf = mediafile.MediaFile(item.path)
mf = mediafile.MediaFile(util.syspath(item.path))
mf.art = art
mf.save()
@ -103,7 +107,7 @@ class ScrubPlugin(BeetsPlugin):
"""
classes = []
for modname, clsname in _MUTAGEN_FORMATS.items():
mod = __import__('mutagen.{0}'.format(modname),
mod = __import__(b'mutagen.{0}'.format(modname),
fromlist=[clsname])
classes.append(getattr(mod, clsname))
return classes

View file

@ -6,6 +6,12 @@ Changelog
Features:
* The summary shown to compare duplicate albums during import now displays
the old and new filesizes. :bug:`1291`
* The colors used are now configurable via the new config option ``colors``,
nested under the option ``ui``. The `color` config option has been moved
from top-level to under ``ui``. Beets will respect the old color setting,
but will warn the user with a deprecation message. :bug:`1238`
* A new :doc:`/plugins/filefilter` lets you write regular expressions to
automatically avoid importing certain files. Thanks to :user:`mried`.
:bug:`1186`
@ -24,6 +30,9 @@ Features:
* :doc:`plugins/mbsync`: A new ``-f/--format`` option controls the output
format when listing unrecognized items. The output is also now more helpful
by default. :bug:`1246`
* :doc:`/plugins/fetchart`: New option ``-n`` to extract the cover art of all
matched albums into its directory. It's also possible to automatically
associate them with the album when adding ``-a``. :bug:`1261`
* :doc:`/plugins/fetchart`: Names of extracted image art is taken from the
``art_filename`` configuration option. :bug:`1258`
* :doc:`/plugins/fetchart`: There's a new Wikipedia image source that uses
@ -32,6 +41,11 @@ Features:
album folders for all freedesktop.org-compliant file managers. This replaces
the :doc:`/plugins/freedesktop` which only worked with the Dolphin file
manager.
* :doc:`/plugins/info`: New options ``-i`` to display only given
properties. :bug:`1287`
* A new ``filesize`` field on items indicates the number of bytes in the file.
:bug:`1291`
* The number of missing/unmatched tracks is shown during import. :bug:`1088`
Core changes:
@ -79,6 +93,18 @@ Fixes:
* :doc:`/plugins/importfeeds` and :doc:`/plugins/smartplaylist`: Automatically
create parent directories for playlist files (instead of crashing when the
parent directory does not exist). :bug:`1266`
* The :ref:`write-cmd` command no longer tries to "write" non-writable fields
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`
* :doc:`/plugins/embedart`: Fix a crash that occured when used together
with the *check* plugin. :bug:`1241`
* :doc:`/plugins/scrub`: Log an error instead of stopping when the ``beet
scrub`` command cannot write a file. Also, avoid problems on Windows with
Unicode filenames. :bug:`1297`
* :doc:`/plugins/discogs`: Handle and log more kinds of communication
errors. :bug:`1299` :bug:`1305`
For developers:
@ -88,7 +114,8 @@ For developers:
should!) use modern ``{}``-style string formatting lazily. See
:ref:`plugin-logging` in the plugin API docs.
* A new ``import_task_created`` event lets you manipulate import tasks
immediately after they are initialized.
immediately after they are initialized. It's also possible to replace the
originally created tasks by returning new ones using this event.
1.3.10 (January 5, 2015)

View file

@ -175,9 +175,11 @@ The events currently available are:
written to disk (i.e., just after the file on disk is closed).
* *import_task_created*: called immediately after an import task is
initialized. Plugins can use this to, for example, cancel processing of a
task before anything else happens. ``task`` (an `ImportTask`) and
``session`` (an `ImportSession`).
initialized. Plugins can use this to, for example, change imported files of a
task before anything else happens. It's also possible to replace the task
with another task by returning a list of tasks. This list can contain zero
or more `ImportTask`s. Returning an empty list will stop the task.
Parameters: ``task`` (an `ImportTask`) and ``session`` (an `ImportSession`).
* *import_task_start*: called when before an import task begins processing.
Parameters: ``task`` and ``session``.

View file

@ -12,7 +12,7 @@ Installing
You will need Python. (Beets is written for `Python 2.7`_, but it works with
2.6 as well. Python 3.x is not yet supported.)
.. _Python 2.7: http://www.python.org/download/releases/2.7.2/
.. _Python 2.7: http://www.python.org/download/
* **Mac OS X** v10.7 (Lion) and 10.8 (Mountain Lion) include Python 2.7 out of
the box; Snow Leopard ships with Python 2.6.
@ -40,6 +40,16 @@ You will need Python. (Beets is written for `Python 2.7`_, but it works with
* For **Slackware**, there's a `SlackBuild`_ available.
* On **Fedora 21**, you there is a `copr`_ for beets, which you can install
using `DNF`_ like so::
$ yum install dnf dnf-plugins-core
$ dnf copr enable afreof/beets
$ yum update
$ yum install beets
.. _copr: https://copr.fedoraproject.org/coprs/afreof/beets/
.. _dnf: http://fedoraproject.org/wiki/Features/DNF
.. _SlackBuild: http://slackbuilds.org/repository/14.1/multimedia/beets/
.. _beets port: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets
.. _beets from AUR: http://aur.archlinux.org/packages.php?ID=39577

View file

@ -77,12 +77,19 @@ embedded album art:
use a specific image file from the filesystem; otherwise, each album embeds
its own currently associated album art.
* ``beet extractart [-o FILE] QUERY``: extracts the image from an item matching
the query and stores it in a file. You can specify the destination file using
the ``-o`` option, but leave off the extension: it will be chosen
automatically. The destination filename is specified using the
``art_filename`` configuration option. It defaults to ``cover`` if it's not
specified via ``-o`` nor the config.
* ``beet extractart [-a] [-n FILE] QUERY``: extracts the images for all albums
matching the query. The images are placed inside the album folder. You can
specify the destination file name using the ``-n`` option, but leave off the
extension: it will be chosen automatically. The destination filename is
specified using the ``art_filename`` configuration option. It defaults to
``cover`` if it's not specified via ``-o`` nor the config.
Using ``-a``, the extracted image files are automatically associated with the
corresponding album.
* ``beet extractart -o FILE QUERY``: extracts the image from an item matching
the query and stores it in a file. You have to specify the destination file
using the ``-o`` option, but leave off the extension: it will be chosen
automatically.
* ``beet clearart QUERY``: removes all embedded images from all items matching
the query. (Use with caution!)

View file

@ -18,7 +18,18 @@ your library::
$ beet info beatles
Command-line options include:
If you just want to see specific properties you can use the
``--include-keys`` option to filter them. The argument is a
comma-separated list of simple glob patterns where ``*`` matches any
string. For example::
$ beet info -i 'title,mb*' beatles
Will only show the ``title`` property and all properties starting with
``mb``. You can add the ``-i`` option multiple times to the command
line.
Additional command-line options include:
* ``--library`` or ``-l``: Show data from the library database instead of the
files' tags.

View file

@ -162,13 +162,6 @@ Either ``yes`` or ``no``, indicating whether the autotagger should use
multiple threads. This makes things faster but may behave strangely.
Defaults to ``yes``.
color
~~~~~
Either ``yes`` or ``no``; whether to use color in console output (currently
only in the ``import`` command). Turn this off if your terminal doesn't
support ANSI colors.
.. _list_format_item:
list_format_item
@ -277,6 +270,49 @@ version of ID3. Enable this option to instead use the older ID3v2.3 standard,
which is preferred by certain older software such as Windows Media Player.
UI Options
----------
The options that allow for customization of the visual appearance
of the console interface.
These options are available in this section:
color
~~~~~
Either ``yes`` or ``no``; whether to use color in console output (currently
only in the ``import`` command). Turn this off if your terminal doesn't
support ANSI colors.
.. note::
The `color` option was previously a top-level configuration. This is
still respected, but a deprecation message will be shown until your
top-level `color` configuration has been nested under `ui`.
colors
~~~~~~
The colors that are used throughout the user interface. These are only used if
the ``color`` option is set to ``yes``. For example, you might have a section
in your configuration file that looks like this::
ui:
color: yes
colors:
text_success: green
text_warning: yellow
text_error: red
text_highlight: red
text_highlight_minor: lightgray
action_default: turquoise
action: blue
Available colors: black, darkred, darkgreen, brown, darkblue, purple, teal,
lightgray, darkgray, red, green, yellow, blue, fuchsia, turquoise, white
Importer Options
----------------

View file

@ -6,4 +6,5 @@ eval-attr="!=slow"
[flake8]
# E241 missing whitespace after ',' (used to align visually)
# E221 multiple spaces before operator (used to align visually)
ignore=E241,E221
# E731 do not assign a lambda expression, use a def
ignore=E241,E221,E731

View file

@ -30,7 +30,7 @@ except ImportError:
import unittest
# Mangle the search path to include the beets sources.
sys.path.insert(0, '..')
sys.path.insert(0, '..') # noqa
import beets.library
from beets import importer, logging
from beets.ui import commands

View file

@ -168,7 +168,7 @@ class TestHelper(object):
self.config['plugins'] = []
self.config['verbose'] = True
self.config['color'] = False
self.config['ui']['color'] = False
self.config['threaded'] = False
self.libdir = os.path.join(self.temp_dir, 'libdir')
@ -199,8 +199,11 @@ class TestHelper(object):
beets.config['plugins'] = plugins
beets.plugins.load_plugins(plugins)
beets.plugins.find_plugins()
Item._types = beets.plugins.types(Item)
Album._types = beets.plugins.types(Album)
# Take a backup of the original _types to restore when unloading
Item._original_types = dict(Item._types)
Album._original_types = dict(Album._types)
Item._types.update(beets.plugins.types(Item))
Album._types.update(beets.plugins.types(Album))
def unload_plugins(self):
"""Unload all plugins and remove the from the configuration.
@ -209,8 +212,8 @@ class TestHelper(object):
beets.config['plugins'] = []
beets.plugins._classes = set()
beets.plugins._instances = {}
Item._types = {}
Album._types = {}
Item._types = Item._original_types
Album._types = Album._original_types
def create_importer(self, item_count=1, album_count=1):
"""Create files to import and return corresponding session.
@ -407,7 +410,7 @@ class TestHelper(object):
def run_with_output(self, *args):
with capture_stdout() as out:
self.run_command(*args)
return out.getvalue()
return out.getvalue().decode('utf-8')
# Safe file operations

View file

@ -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'))

View file

@ -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

View file

@ -19,6 +19,7 @@ from test._common import unittest
from test.helper import TestHelper
from beets.mediafile import MediaFile
from beets.util import displayable_path
class InfoTest(unittest.TestCase, TestHelper):
@ -52,17 +53,17 @@ class InfoTest(unittest.TestCase, TestHelper):
self.assertNotIn('composer:', out)
def test_item_query(self):
items = self.add_item_fixtures(count=2)
items[0].album = 'xxxx'
items[0].write()
items[0].album = 'yyyy'
items[0].store()
item1, item2 = self.add_item_fixtures(count=2)
item1.album = 'xxxx'
item1.write()
item1.album = 'yyyy'
item1.store()
out = self.run_with_output('album:yyyy')
self.assertIn(items[0].path, out)
self.assertIn(b'album: xxxx', out)
self.assertIn(displayable_path(item1.path), out)
self.assertIn(u'album: xxxx', out)
self.assertNotIn(items[1].path, out)
self.assertNotIn(displayable_path(item2.path), out)
def test_item_library_query(self):
item, = self.add_item_fixtures()
@ -70,8 +71,8 @@ class InfoTest(unittest.TestCase, TestHelper):
item.store()
out = self.run_with_output('--library', 'album:xxxx')
self.assertIn(item.path, out)
self.assertIn(b'album: xxxx', out)
self.assertIn(displayable_path(item.path), out)
self.assertIn(u'album: xxxx', out)
def test_collect_item_and_path(self):
path = self.create_mediafile_fixture()
@ -88,9 +89,20 @@ class InfoTest(unittest.TestCase, TestHelper):
mediafile.save()
out = self.run_with_output('--summarize', 'album:AAA', path)
self.assertIn('album: AAA', out)
self.assertIn('tracktotal: 5', out)
self.assertIn('title: [various]', out)
self.assertIn(u'album: AAA', out)
self.assertIn(u'tracktotal: 5', out)
self.assertIn(u'title: [various]', out)
def test_include_pattern(self):
item, = self.add_item_fixtures()
item.album = 'xxxx'
item.store()
out = self.run_with_output('--library', 'album:xxxx',
'--include-keys', '*lbu*')
self.assertIn(displayable_path(item.path), out)
self.assertNotIn(u'title:', out)
self.assertIn(u'album: xxxx', out)
def suite():

View file

@ -31,10 +31,12 @@ from test._common import unittest
from test._common import item
import beets.library
import beets.mediafile
import beets.dbcore
from beets import util
from beets import plugins
from beets import config
from beets.mediafile import MediaFile
from test.helper import TestHelper
# Shortcut to path normalization.
np = util.normpath
@ -1109,37 +1111,49 @@ class UnicodePathTest(_common.LibTestCase):
self.i.write()
class WriteTest(_common.LibTestCase):
class WriteTest(unittest.TestCase, TestHelper):
def setUp(self):
self.setup_beets()
def tearDown(self):
self.teardown_beets()
def test_write_nonexistant(self):
self.i.path = '/path/does/not/exist'
self.assertRaises(beets.library.ReadError, self.i.write)
item = self.create_item()
item.path = '/path/does/not/exist'
with self.assertRaises(beets.library.ReadError):
item.write()
def test_no_write_permission(self):
path = os.path.join(self.temp_dir, 'file.mp3')
shutil.copy(os.path.join(_common.RSRC, 'empty.mp3'), path)
item = self.add_item_fixture()
path = item.path
os.chmod(path, stat.S_IRUSR)
try:
self.i.path = path
self.assertRaises(beets.library.WriteError, self.i.write)
self.assertRaises(beets.library.WriteError, item.write)
finally:
# Restore write permissions so the file can be cleaned up.
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
def test_write_with_custom_path(self):
custom_path = os.path.join(self.temp_dir, 'file.mp3')
self.i.path = os.path.join(self.temp_dir, 'item_file.mp3')
shutil.copy(os.path.join(_common.RSRC, 'empty.mp3'), custom_path)
shutil.copy(os.path.join(_common.RSRC, 'empty.mp3'), self.i.path)
item = self.add_item_fixture()
custom_path = os.path.join(self.temp_dir, 'custom.mp3')
shutil.copy(item.path, custom_path)
self.i['artist'] = 'new artist'
item['artist'] = 'new artist'
self.assertNotEqual(MediaFile(custom_path).artist, 'new artist')
self.assertNotEqual(MediaFile(self.i.path).artist, 'new artist')
self.assertNotEqual(MediaFile(item.path).artist, 'new artist')
self.i.write(custom_path)
item.write(custom_path)
self.assertEqual(MediaFile(custom_path).artist, 'new artist')
self.assertNotEqual(MediaFile(self.i.path).artist, 'new artist')
self.assertNotEqual(MediaFile(item.path).artist, 'new artist')
def test_write_custom_tags(self):
item = self.add_item_fixture(artist='old artist')
item.write(tags={'artist': 'new artist'})
self.assertNotEqual(item.artist, 'new artist')
self.assertEqual(MediaFile(item.path).artist, 'new artist')
class ItemReadTest(unittest.TestCase):
@ -1158,6 +1172,12 @@ class ItemReadTest(unittest.TestCase):
item.read('/thisfiledoesnotexist')
class ParseQueryTest(unittest.TestCase):
def test_parse_invalid_query_string(self):
with self.assertRaises(beets.dbcore.InvalidQueryError):
beets.library.parse_query_string('foo"', None)
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)

View file

@ -207,6 +207,26 @@ class EventsTest(unittest.TestCase, ImportHelper, TestHelper):
self.file_paths.append(dest_path)
def test_import_task_created(self):
import_files = [self.import_dir]
self._setup_import_session(singletons=False)
self.importer.paths = import_files
with helper.capture_log() as logs:
self.importer.run()
self.unload_plugins()
# Exactly one event should have been imported (for the album).
# Sentinels do not get emitted.
self.assertEqual(logs.count('Sending event: import_task_created'), 1)
logs = [line for line in logs if not line.startswith('Sending event:')]
self.assertEqual(logs, [
'Album: {0}'.format(os.path.join(self.import_dir, 'album')),
' {0}'.format(self.file_paths[0]),
' {0}'.format(self.file_paths[1]),
])
def test_import_task_created_with_plugin(self):
class ToSingletonPlugin(plugins.BeetsPlugin):
def __init__(self):
super(ToSingletonPlugin, self).__init__()
@ -243,9 +263,8 @@ class EventsTest(unittest.TestCase, ImportHelper, TestHelper):
logs = [line for line in logs if not line.startswith('Sending event:')]
self.assertEqual(logs, [
'Album: {0}/album'.format(self.import_dir),
' {0}'.format(self.file_paths[0]),
' {0}'.format(self.file_paths[1]),
'Singleton: {0}'.format(self.file_paths[0]),
'Singleton: {0}'.format(self.file_paths[1]),
])

View file

@ -24,7 +24,7 @@ from test import helper
import beets.library
from beets import dbcore
from beets.dbcore import types
from beets.dbcore.query import NoneQuery, InvalidQueryError
from beets.dbcore.query import NoneQuery, InvalidQueryArgumentTypeError
from beets.library import Library, Item
@ -59,9 +59,12 @@ class AnyFieldQueryTest(_common.LibTestCase):
class AssertsMixin(object):
def assert_matched(self, results, titles):
def assert_items_matched(self, results, titles):
self.assertEqual([i.title for i in results], titles)
def assert_albums_matched(self, results, albums):
self.assertEqual([a.album for a in results], albums)
# A test case class providing a library with some dummy data and some
# assertions involving that data.
@ -89,8 +92,8 @@ class DummyDataTestCase(_common.TestCase, AssertsMixin):
self.lib.add(item)
self.lib.add_album(items[:2])
def assert_matched_all(self, results):
self.assert_matched(results, [
def assert_items_matched_all(self, results):
self.assert_items_matched(results, [
'foo bar',
'baz qux',
'beets 4 eva',
@ -101,72 +104,72 @@ class GetTest(DummyDataTestCase):
def test_get_empty(self):
q = ''
results = self.lib.items(q)
self.assert_matched_all(results)
self.assert_items_matched_all(results)
def test_get_none(self):
q = None
results = self.lib.items(q)
self.assert_matched_all(results)
self.assert_items_matched_all(results)
def test_get_one_keyed_term(self):
q = 'title:qux'
results = self.lib.items(q)
self.assert_matched(results, ['baz qux'])
self.assert_items_matched(results, ['baz qux'])
def test_get_one_keyed_regexp(self):
q = r'artist::t.+r'
results = self.lib.items(q)
self.assert_matched(results, ['beets 4 eva'])
self.assert_items_matched(results, ['beets 4 eva'])
def test_get_one_unkeyed_term(self):
q = 'three'
results = self.lib.items(q)
self.assert_matched(results, ['beets 4 eva'])
self.assert_items_matched(results, ['beets 4 eva'])
def test_get_one_unkeyed_regexp(self):
q = r':x$'
results = self.lib.items(q)
self.assert_matched(results, ['baz qux'])
self.assert_items_matched(results, ['baz qux'])
def test_get_no_matches(self):
q = 'popebear'
results = self.lib.items(q)
self.assert_matched(results, [])
self.assert_items_matched(results, [])
def test_invalid_key(self):
q = 'pope:bear'
results = self.lib.items(q)
# Matches nothing since the flexattr is not present on the
# objects.
self.assert_matched(results, [])
self.assert_items_matched(results, [])
def test_term_case_insensitive(self):
q = 'oNE'
results = self.lib.items(q)
self.assert_matched(results, ['foo bar'])
self.assert_items_matched(results, ['foo bar'])
def test_regexp_case_sensitive(self):
q = r':oNE'
results = self.lib.items(q)
self.assert_matched(results, [])
self.assert_items_matched(results, [])
q = r':one'
results = self.lib.items(q)
self.assert_matched(results, ['foo bar'])
self.assert_items_matched(results, ['foo bar'])
def test_term_case_insensitive_with_key(self):
q = 'artist:thrEE'
results = self.lib.items(q)
self.assert_matched(results, ['beets 4 eva'])
self.assert_items_matched(results, ['beets 4 eva'])
def test_key_case_insensitive(self):
q = 'ArTiST:three'
results = self.lib.items(q)
self.assert_matched(results, ['beets 4 eva'])
self.assert_items_matched(results, ['beets 4 eva'])
def test_unkeyed_term_matches_multiple_columns(self):
q = 'baz'
results = self.lib.items(q)
self.assert_matched(results, [
self.assert_items_matched(results, [
'foo bar',
'baz qux',
])
@ -174,7 +177,7 @@ class GetTest(DummyDataTestCase):
def test_unkeyed_regexp_matches_multiple_columns(self):
q = r':z$'
results = self.lib.items(q)
self.assert_matched(results, [
self.assert_items_matched(results, [
'foo bar',
'baz qux',
])
@ -182,41 +185,41 @@ class GetTest(DummyDataTestCase):
def test_keyed_term_matches_only_one_column(self):
q = 'title:baz'
results = self.lib.items(q)
self.assert_matched(results, ['baz qux'])
self.assert_items_matched(results, ['baz qux'])
def test_keyed_regexp_matches_only_one_column(self):
q = r'title::baz'
results = self.lib.items(q)
self.assert_matched(results, [
self.assert_items_matched(results, [
'baz qux',
])
def test_multiple_terms_narrow_search(self):
q = 'qux baz'
results = self.lib.items(q)
self.assert_matched(results, [
self.assert_items_matched(results, [
'baz qux',
])
def test_multiple_regexps_narrow_search(self):
q = r':baz :qux'
results = self.lib.items(q)
self.assert_matched(results, ['baz qux'])
self.assert_items_matched(results, ['baz qux'])
def test_mixed_terms_regexps_narrow_search(self):
q = r':baz qux'
results = self.lib.items(q)
self.assert_matched(results, ['baz qux'])
self.assert_items_matched(results, ['baz qux'])
def test_single_year(self):
q = 'year:2001'
results = self.lib.items(q)
self.assert_matched(results, ['foo bar'])
self.assert_items_matched(results, ['foo bar'])
def test_year_range(self):
q = 'year:2000..2002'
results = self.lib.items(q)
self.assert_matched(results, [
self.assert_items_matched(results, [
'foo bar',
'baz qux',
])
@ -224,22 +227,22 @@ class GetTest(DummyDataTestCase):
def test_singleton_true(self):
q = 'singleton:true'
results = self.lib.items(q)
self.assert_matched(results, ['beets 4 eva'])
self.assert_items_matched(results, ['beets 4 eva'])
def test_singleton_false(self):
q = 'singleton:false'
results = self.lib.items(q)
self.assert_matched(results, ['foo bar', 'baz qux'])
self.assert_items_matched(results, ['foo bar', 'baz qux'])
def test_compilation_true(self):
q = 'comp:true'
results = self.lib.items(q)
self.assert_matched(results, ['foo bar', 'baz qux'])
self.assert_items_matched(results, ['foo bar', 'baz qux'])
def test_compilation_false(self):
q = 'comp:false'
results = self.lib.items(q)
self.assert_matched(results, ['beets 4 eva'])
self.assert_items_matched(results, ['beets 4 eva'])
def test_unknown_field_name_no_results(self):
q = 'xyzzy:nonsense'
@ -266,7 +269,7 @@ class GetTest(DummyDataTestCase):
q = u'title:caf\xe9'
results = self.lib.items(q)
self.assert_matched(results, [u'caf\xe9'])
self.assert_items_matched(results, [u'caf\xe9'])
def test_numeric_search_positive(self):
q = dbcore.query.NumericQuery('year', '2001')
@ -279,14 +282,15 @@ class GetTest(DummyDataTestCase):
self.assertFalse(results)
def test_invalid_query(self):
with self.assertRaises(InvalidQueryError) as raised:
with self.assertRaises(InvalidQueryArgumentTypeError) as raised:
dbcore.query.NumericQuery('year', '199a')
self.assertIn('not an int', unicode(raised.exception))
with self.assertRaises(InvalidQueryError) as raised:
with self.assertRaises(InvalidQueryArgumentTypeError) as raised:
dbcore.query.RegexpQuery('year', '199(')
self.assertIn('not a regular expression', unicode(raised.exception))
self.assertIn('unbalanced parenthesis', unicode(raised.exception))
self.assertIsInstance(raised.exception, TypeError)
class MatchTest(_common.TestCase):
@ -334,81 +338,127 @@ 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):
super(PathQueryTest, self).setUp()
self.i.path = '/a/b/c.mp3'
self.i.title = 'path item'
self.i.album = 'path album'
self.i.store()
self.lib.add_album([self.i])
def test_path_exact_match(self):
q = 'path:/a/b/c.mp3'
results = self.lib.items(q)
self.assert_matched(results, ['path item'])
self.assert_items_matched(results, ['path item'])
results = self.lib.albums(q)
self.assert_albums_matched(results, [])
def test_parent_directory_no_slash(self):
q = 'path:/a'
results = self.lib.items(q)
self.assert_matched(results, ['path item'])
self.assert_items_matched(results, ['path item'])
results = self.lib.albums(q)
self.assert_albums_matched(results, ['path album'])
def test_parent_directory_with_slash(self):
q = 'path:/a/'
results = self.lib.items(q)
self.assert_matched(results, ['path item'])
self.assert_items_matched(results, ['path item'])
results = self.lib.albums(q)
self.assert_albums_matched(results, ['path album'])
def test_no_match(self):
q = 'path:/xyzzy/'
results = self.lib.items(q)
self.assert_matched(results, [])
self.assert_items_matched(results, [])
results = self.lib.albums(q)
self.assert_albums_matched(results, [])
def test_fragment_no_match(self):
q = 'path:/b/'
results = self.lib.items(q)
self.assert_matched(results, [])
self.assert_items_matched(results, [])
results = self.lib.albums(q)
self.assert_albums_matched(results, [])
def test_nonnorm_path(self):
q = 'path:/x/../a/b'
results = self.lib.items(q)
self.assert_matched(results, ['path item'])
self.assert_items_matched(results, ['path item'])
results = self.lib.albums(q)
self.assert_albums_matched(results, ['path album'])
def test_slashed_query_matches_path(self):
q = '/a/b'
results = self.lib.items(q)
self.assert_matched(results, ['path item'])
self.assert_items_matched(results, ['path item'])
results = self.lib.albums(q)
self.assert_albums_matched(results, ['path album'])
def test_non_slashed_does_not_match_path(self):
q = 'c.mp3'
results = self.lib.items(q)
self.assert_matched(results, [])
self.assert_items_matched(results, [])
results = self.lib.albums(q)
self.assert_albums_matched(results, [])
def test_slashes_in_explicit_field_does_not_match_path(self):
q = 'title:/a/b'
results = self.lib.items(q)
self.assert_matched(results, [])
self.assert_items_matched(results, [])
def test_path_regex(self):
def test_path_item_regex(self):
q = 'path::\\.mp3$'
results = self.lib.items(q)
self.assert_matched(results, ['path item'])
self.assert_items_matched(results, ['path item'])
def test_path_album_regex(self):
q = 'path::b'
results = self.lib.albums(q)
self.assert_albums_matched(results, ['path album'])
def test_escape_underscore(self):
self.add_item(path='/a/_/title.mp3', title='with underscore')
self.add_album(path='/a/_/title.mp3', title='with underscore',
album='album with underscore')
q = 'path:/a/_'
results = self.lib.items(q)
self.assert_matched(results, ['with underscore'])
self.assert_items_matched(results, ['with underscore'])
results = self.lib.albums(q)
self.assert_albums_matched(results, ['album with underscore'])
def test_escape_percent(self):
self.add_item(path='/a/%/title.mp3', title='with percent')
self.add_album(path='/a/%/title.mp3', title='with percent',
album='album with percent')
q = 'path:/a/%'
results = self.lib.items(q)
self.assert_matched(results, ['with percent'])
self.assert_items_matched(results, ['with percent'])
results = self.lib.albums(q)
self.assert_albums_matched(results, ['album with percent'])
def test_escape_backslash(self):
self.add_item(path=r'/a/\x/title.mp3', title='with backslash')
self.add_album(path=r'/a/\x/title.mp3', title='with backslash',
album='album with backslash')
q = r'path:/a/\\x'
results = self.lib.items(q)
self.assert_matched(results, ['with backslash'])
self.assert_items_matched(results, ['with backslash'])
results = self.lib.albums(q)
self.assert_albums_matched(results, ['album with backslash'])
class IntQueryTest(unittest.TestCase, TestHelper):
@ -514,11 +564,11 @@ class DefaultSearchFieldsTest(DummyDataTestCase):
def test_items_matches_title(self):
items = self.lib.items('beets')
self.assert_matched(items, ['beets 4 eva'])
self.assert_items_matched(items, ['beets 4 eva'])
def test_items_does_not_match_year(self):
items = self.lib.items('2001')
self.assert_matched(items, [])
self.assert_items_matched(items, [])
class NoneQueryTest(unittest.TestCase, TestHelper):

View file

@ -318,6 +318,37 @@ class WriteTest(unittest.TestCase, TestHelper):
item = self.lib.items().get()
self.assertEqual(item.mtime, item.current_mtime())
def test_non_metadata_field_unchanged(self):
"""Changing a non-"tag" field like `bitrate` and writing should
have no effect.
"""
# An item that starts out "clean".
item = self.add_item_fixture()
item.read()
# ... but with a mismatched bitrate.
item.bitrate = 123
item.store()
with capture_stdout() as stdout:
self.write_cmd()
self.assertEqual(stdout.getvalue(), '')
def test_write_metadata_field(self):
item = self.add_item_fixture()
item.read()
old_title = item.title
item.title = 'new title'
item.store()
with capture_stdout() as stdout:
self.write_cmd()
self.assertTrue('{0} -> new title'.format(old_title)
in stdout.getvalue())
class MoveTest(_common.TestCase):
def setUp(self):
@ -892,7 +923,7 @@ class ShowChangeTest(_common.TestCase):
items = items or self.items
info = info or self.info
mapping = dict(zip(items, info.tracks))
config['color'] = False
config['ui']['color'] = False
album_dist = distance(items, info, mapping)
album_dist._penalties = {'album': [dist]}
commands.show_change(

View file

@ -8,7 +8,7 @@ from test import _common
import json
import beetsplug
from beets.library import Item, Album
beetsplug.__path__ = ['./beetsplug', '../beetsplug']
beetsplug.__path__ = ['./beetsplug', '../beetsplug'] # noqa
from beetsplug import web

11
tox.ini
View file

@ -19,14 +19,12 @@ deps =
pathlib
pyxdg
commands =
nosetests -v {posargs}
nosetests {posargs}
[testenv:py26]
deps =
{[testenv]deps}
unittest2
commands =
python ./setup.py test {posargs}
[testenv:py27cov]
basepython = python2.7
@ -34,7 +32,12 @@ deps =
{[testenv]deps}
coverage
commands =
nosetests -v --with-coverage {posargs}
nosetests --with-coverage {posargs}
[testenv:py27setup]
basepython = python2.7
commands =
python ./setup.py test {posargs}
[testenv:docs]
changedir = docs