mirror of
https://github.com/beetbox/beets.git
synced 2026-01-06 07:53:40 +01:00
Merge branch 'master' into thumbnails
Conflicts: docs/changelog.rst
This commit is contained in:
commit
c6455c269f
41 changed files with 831 additions and 300 deletions
|
|
@ -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__)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import urllib
|
|||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst
|
||||
import gst # noqa
|
||||
|
||||
|
||||
class GstPlayer(object):
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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``.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
----------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,62 @@ class FtInTitlePluginTest(unittest.TestCase):
|
|||
"""Set up configuration"""
|
||||
ftintitle.FtInTitlePlugin()
|
||||
|
||||
def test_find_feat_part(self):
|
||||
test_cases = [
|
||||
{
|
||||
'artist': 'Alice ft. Bob',
|
||||
'album_artist': 'Alice',
|
||||
'feat_part': 'Bob'
|
||||
},
|
||||
{
|
||||
'artist': 'Alice feat Bob',
|
||||
'album_artist': 'Alice',
|
||||
'feat_part': 'Bob'
|
||||
},
|
||||
{
|
||||
'artist': 'Alice featuring Bob',
|
||||
'album_artist': 'Alice',
|
||||
'feat_part': 'Bob'
|
||||
},
|
||||
{
|
||||
'artist': 'Alice & Bob',
|
||||
'album_artist': 'Alice',
|
||||
'feat_part': 'Bob'
|
||||
},
|
||||
{
|
||||
'artist': 'Alice and Bob',
|
||||
'album_artist': 'Alice',
|
||||
'feat_part': 'Bob'
|
||||
},
|
||||
{
|
||||
'artist': 'Alice With Bob',
|
||||
'album_artist': 'Alice',
|
||||
'feat_part': 'Bob'
|
||||
},
|
||||
{
|
||||
'artist': 'Alice defeat Bob',
|
||||
'album_artist': 'Alice',
|
||||
'feat_part': None
|
||||
},
|
||||
{
|
||||
'artist': 'Alice & Bob',
|
||||
'album_artist': 'Bob',
|
||||
'feat_part': 'Alice'
|
||||
},
|
||||
{
|
||||
'artist': 'Alice ft. Bob',
|
||||
'album_artist': 'Bob',
|
||||
'feat_part': 'Alice'
|
||||
},
|
||||
]
|
||||
|
||||
for test_case in test_cases:
|
||||
feat_part = ftintitle.find_feat_part(
|
||||
test_case['artist'],
|
||||
test_case['album_artist']
|
||||
)
|
||||
self.assertEqual(feat_part, test_case['feat_part'])
|
||||
|
||||
def test_split_on_feat(self):
|
||||
parts = ftintitle.split_on_feat('Alice ft. Bob')
|
||||
self.assertEqual(parts, ('Alice', 'Bob'))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is part of beets.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
|
|
@ -22,6 +21,8 @@ import os
|
|||
import re
|
||||
import shutil
|
||||
import StringIO
|
||||
import unicodedata
|
||||
import sys
|
||||
from tempfile import mkstemp
|
||||
from zipfile import ZipFile
|
||||
from tarfile import TarFile
|
||||
|
|
@ -1233,8 +1234,8 @@ class TagLogTest(_common.TestCase):
|
|||
sio = StringIO.StringIO()
|
||||
handler = logging.StreamHandler(sio)
|
||||
session = _common.import_session(loghandler=handler)
|
||||
session.tag_log('status', u'café') # send unicode
|
||||
self.assertIn(u'status café', sio.getvalue())
|
||||
session.tag_log('status', u'caf\xe9') # send unicode
|
||||
self.assertIn(u'status caf\xe9', sio.getvalue())
|
||||
|
||||
|
||||
class ResumeImportTest(unittest.TestCase, TestHelper):
|
||||
|
|
@ -1380,49 +1381,77 @@ class AlbumsInDirTest(_common.TestCase):
|
|||
|
||||
|
||||
class MultiDiscAlbumsInDirTest(_common.TestCase):
|
||||
def setUp(self):
|
||||
super(MultiDiscAlbumsInDirTest, self).setUp()
|
||||
def create_music(self, files=True, ascii=True):
|
||||
"""Create some music in multiple album directories.
|
||||
|
||||
self.base = os.path.abspath(os.path.join(self.temp_dir, 'tempdir'))
|
||||
`files` indicates whether to create the files (otherwise, only
|
||||
directories are made). `ascii` indicates ACII-only filenames;
|
||||
otherwise, we use Unicode names.
|
||||
"""
|
||||
self.base = os.path.abspath(os.path.join(self.temp_dir, b'tempdir'))
|
||||
os.mkdir(self.base)
|
||||
|
||||
name = b'CAT' if ascii else u'C\xc1T'.encode('utf8')
|
||||
name_alt_case = b'CAt' if ascii else u'C\xc1t'.encode('utf8')
|
||||
|
||||
self.dirs = [
|
||||
# Nested album, multiple subdirs.
|
||||
# Also, false positive marker in root dir, and subtitle for disc 3.
|
||||
os.path.join(self.base, 'ABCD1234'),
|
||||
os.path.join(self.base, 'ABCD1234', 'cd 1'),
|
||||
os.path.join(self.base, 'ABCD1234', 'cd 3 - bonus'),
|
||||
os.path.join(self.base, b'ABCD1234'),
|
||||
os.path.join(self.base, b'ABCD1234', b'cd 1'),
|
||||
os.path.join(self.base, b'ABCD1234', b'cd 3 - bonus'),
|
||||
|
||||
# Nested album, single subdir.
|
||||
# Also, punctuation between marker and disc number.
|
||||
os.path.join(self.base, 'album'),
|
||||
os.path.join(self.base, 'album', 'cd _ 1'),
|
||||
os.path.join(self.base, b'album'),
|
||||
os.path.join(self.base, b'album', b'cd _ 1'),
|
||||
|
||||
# Flattened album, case typo.
|
||||
# Also, false positive marker in parent dir.
|
||||
os.path.join(self.base, 'artist [CD5]'),
|
||||
os.path.join(self.base, 'artist [CD5]', 'CAT disc 1'),
|
||||
os.path.join(self.base, 'artist [CD5]', 'CAt disc 2'),
|
||||
os.path.join(self.base, b'artist [CD5]'),
|
||||
os.path.join(self.base, b'artist [CD5]', name + b' disc 1'),
|
||||
os.path.join(self.base, b'artist [CD5]',
|
||||
name_alt_case + b' disc 2'),
|
||||
|
||||
# Single disc album, sorted between CAT discs.
|
||||
os.path.join(self.base, 'artist [CD5]', 'CATS'),
|
||||
os.path.join(self.base, b'artist [CD5]', name + b'S'),
|
||||
]
|
||||
self.files = [
|
||||
os.path.join(self.base, 'ABCD1234', 'cd 1', 'song1.mp3'),
|
||||
os.path.join(self.base, 'ABCD1234', 'cd 3 - bonus', 'song2.mp3'),
|
||||
os.path.join(self.base, 'ABCD1234', 'cd 3 - bonus', 'song3.mp3'),
|
||||
os.path.join(self.base, 'album', 'cd _ 1', 'song4.mp3'),
|
||||
os.path.join(self.base, 'artist [CD5]', 'CAT disc 1', 'song5.mp3'),
|
||||
os.path.join(self.base, 'artist [CD5]', 'CAt disc 2', 'song6.mp3'),
|
||||
os.path.join(self.base, 'artist [CD5]', 'CATS', 'song7.mp3'),
|
||||
os.path.join(self.base, b'ABCD1234', b'cd 1', b'song1.mp3'),
|
||||
os.path.join(self.base, b'ABCD1234',
|
||||
b'cd 3 - bonus', b'song2.mp3'),
|
||||
os.path.join(self.base, b'ABCD1234',
|
||||
b'cd 3 - bonus', b'song3.mp3'),
|
||||
os.path.join(self.base, b'album', b'cd _ 1', b'song4.mp3'),
|
||||
os.path.join(self.base, b'artist [CD5]', name + b' disc 1',
|
||||
b'song5.mp3'),
|
||||
os.path.join(self.base, b'artist [CD5]',
|
||||
name_alt_case + b' disc 2', b'song6.mp3'),
|
||||
os.path.join(self.base, b'artist [CD5]', name + b'S',
|
||||
b'song7.mp3'),
|
||||
]
|
||||
|
||||
if not ascii:
|
||||
self.dirs = [self._normalize_path(p) for p in self.dirs]
|
||||
self.files = [self._normalize_path(p) for p in self.files]
|
||||
|
||||
for path in self.dirs:
|
||||
os.mkdir(path)
|
||||
for path in self.files:
|
||||
_mkmp3(path)
|
||||
if files:
|
||||
for path in self.files:
|
||||
_mkmp3(path)
|
||||
|
||||
def _normalize_path(self, path):
|
||||
"""Normalize a path's Unicode combining form according to the
|
||||
platform.
|
||||
"""
|
||||
path = path.decode('utf8')
|
||||
norm_form = 'NFD' if sys.platform == 'darwin' else 'NFC'
|
||||
path = unicodedata.normalize(norm_form, path)
|
||||
return path.encode('utf8')
|
||||
|
||||
def test_coalesce_nested_album_multiple_subdirs(self):
|
||||
self.create_music()
|
||||
albums = list(albums_in_dir(self.base))
|
||||
self.assertEquals(len(albums), 4)
|
||||
root, items = albums[0]
|
||||
|
|
@ -1430,30 +1459,46 @@ class MultiDiscAlbumsInDirTest(_common.TestCase):
|
|||
self.assertEquals(len(items), 3)
|
||||
|
||||
def test_coalesce_nested_album_single_subdir(self):
|
||||
self.create_music()
|
||||
albums = list(albums_in_dir(self.base))
|
||||
root, items = albums[1]
|
||||
self.assertEquals(root, self.dirs[3:5])
|
||||
self.assertEquals(len(items), 1)
|
||||
|
||||
def test_coalesce_flattened_album_case_typo(self):
|
||||
self.create_music()
|
||||
albums = list(albums_in_dir(self.base))
|
||||
root, items = albums[2]
|
||||
self.assertEquals(root, self.dirs[6:8])
|
||||
self.assertEquals(len(items), 2)
|
||||
|
||||
def test_single_disc_album(self):
|
||||
self.create_music()
|
||||
albums = list(albums_in_dir(self.base))
|
||||
root, items = albums[3]
|
||||
self.assertEquals(root, self.dirs[8:])
|
||||
self.assertEquals(len(items), 1)
|
||||
|
||||
def test_do_not_yield_empty_album(self):
|
||||
# Remove all the MP3s.
|
||||
for path in self.files:
|
||||
os.remove(path)
|
||||
self.create_music(files=False)
|
||||
albums = list(albums_in_dir(self.base))
|
||||
self.assertEquals(len(albums), 0)
|
||||
|
||||
def test_single_disc_unicode(self):
|
||||
self.create_music(ascii=False)
|
||||
albums = list(albums_in_dir(self.base))
|
||||
root, items = albums[3]
|
||||
self.assertEquals(root, self.dirs[8:])
|
||||
self.assertEquals(len(items), 1)
|
||||
|
||||
def test_coalesce_multiple_unicode(self):
|
||||
self.create_music(ascii=False)
|
||||
albums = list(albums_in_dir(self.base))
|
||||
self.assertEquals(len(albums), 4)
|
||||
root, items = albums[0]
|
||||
self.assertEquals(root, self.dirs[0:3])
|
||||
self.assertEquals(len(items), 3)
|
||||
|
||||
|
||||
class ReimportTest(unittest.TestCase, ImportHelper):
|
||||
"""Test "re-imports", in which the autotagging machinery is used for
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
11
tox.ini
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue