diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py
index fab0fbbae..2f632f1ee 100644
--- a/beets/autotag/__init__.py
+++ b/beets/autotag/__init__.py
@@ -42,6 +42,8 @@ def apply_item_metadata(item, track_info):
item.mb_trackid = track_info.track_id
if track_info.artist_id:
item.mb_artistid = track_info.artist_id
+ if track_info.data_source:
+ item.data_source = track_info.data_source
# At the moment, the other metadata is left intact (including album
# and track number). Perhaps these should be emptied?
@@ -125,7 +127,8 @@ def apply_metadata(album_info, mapping):
'language',
'country',
'albumstatus',
- 'albumdisambig'):
+ 'albumdisambig',
+ 'data_source',):
value = getattr(album_info, field)
if value is not None:
item[field] = value
diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py
index 12d11a0b3..3a4f96548 100644
--- a/beets/autotag/hooks.py
+++ b/beets/autotag/hooks.py
@@ -137,6 +137,8 @@ class TrackInfo(object):
- ``artist_sort``: name of the track artist for sorting
- ``disctitle``: name of the individual medium (subtitle)
- ``artist_credit``: Recording-specific artist name
+ - ``data_source``: The original data source (MusicBrainz, Discogs, etc.)
+ - ``data_url``: The data source release URL.
Only ``title`` and ``track_id`` are required. The rest of the fields
may be None. The indices ``index``, ``medium``, and ``medium_index``
diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py
index c25599751..b9402f3dc 100644
--- a/beets/autotag/mb.py
+++ b/beets/autotag/mb.py
@@ -161,6 +161,7 @@ def track_info(recording, index=None, medium=None, medium_index=None,
medium=medium,
medium_index=medium_index,
medium_total=medium_total,
+ data_source='MusicBrainz',
data_url=track_url(recording['id']),
)
diff --git a/beets/config_default.yaml b/beets/config_default.yaml
index fcdd34f6d..1bc1d3a9b 100644
--- a/beets/config_default.yaml
+++ b/beets/config_default.yaml
@@ -43,7 +43,7 @@ pluginpath: []
threaded: yes
timeout: 5.0
per_disc_numbering: no
-verbose: no
+verbose: 0
terminal_encoding: utf8
original_date: no
id3v23: no
@@ -61,8 +61,8 @@ ui:
action_default: turquoise
action: blue
-list_format_item: $artist - $album - $title
-list_format_album: $albumartist - $album
+format_item: $artist - $album - $title
+format_album: $albumartist - $album
time_format: '%Y-%m-%d %H:%M:%S'
sort_album: albumartist+ album+
diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py
index 3727f6d7f..7dc897412 100644
--- a/beets/dbcore/query.py
+++ b/beets/dbcore/query.py
@@ -18,13 +18,19 @@ from __future__ import (division, absolute_import, print_function,
unicode_literals)
import re
-from operator import attrgetter
+from operator import attrgetter, mul
from beets import util
from datetime import datetime, timedelta
-class InvalidQueryError(ValueError):
- """Represent any kind of invalid query
+class ParsingError(ValueError):
+ """Abstract class for any unparseable user-requested album/query
+ specification.
+ """
+
+
+class InvalidQueryError(ParsingError):
+ """Represent any kind of invalid query.
The query should be a unicode string or a list, which will be space-joined.
"""
@@ -35,7 +41,7 @@ class InvalidQueryError(ValueError):
super(InvalidQueryError, self).__init__(message)
-class InvalidQueryArgumentTypeError(TypeError):
+class InvalidQueryArgumentTypeError(ParsingError):
"""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
@@ -67,6 +73,15 @@ class Query(object):
"""
raise NotImplementedError
+ def __repr__(self):
+ return "{0.__class__.__name__}()".format(self)
+
+ def __eq__(self, other):
+ return type(self) == type(other)
+
+ def __hash__(self):
+ return 0
+
class FieldQuery(Query):
"""An abstract query that searches in a specific field for a
@@ -100,6 +115,17 @@ class FieldQuery(Query):
def match(self, item):
return self.value_match(self.pattern, item.get(self.field))
+ def __repr__(self):
+ return ("{0.__class__.__name__}({0.field!r}, {0.pattern!r}, "
+ "{0.fast})".format(self))
+
+ def __eq__(self, other):
+ return super(FieldQuery, self).__eq__(other) and \
+ self.field == other.field and self.pattern == other.pattern
+
+ def __hash__(self):
+ return hash((self.field, hash(self.pattern)))
+
class MatchQuery(FieldQuery):
"""A query that looks for exact matches in an item field."""
@@ -114,8 +140,7 @@ class MatchQuery(FieldQuery):
class NoneQuery(FieldQuery):
def __init__(self, field, fast=True):
- self.field = field
- self.fast = fast
+ super(NoneQuery, self).__init__(field, None, fast)
def col_clause(self):
return self.field + " IS NULL", ()
@@ -127,6 +152,9 @@ class NoneQuery(FieldQuery):
except KeyError:
return True
+ def __repr__(self):
+ return "{0.__class__.__name__}({0.field!r}, {0.fast})".format(self)
+
class StringFieldQuery(FieldQuery):
"""A FieldQuery that converts values to strings before matching
@@ -171,8 +199,8 @@ class RegexpQuery(StringFieldQuery):
Raises InvalidQueryError when the pattern is not a valid regular
expression.
"""
- def __init__(self, field, pattern, false=True):
- super(RegexpQuery, self).__init__(field, pattern, false)
+ def __init__(self, field, pattern, fast=True):
+ super(RegexpQuery, self).__init__(field, pattern, fast)
try:
self.pattern = re.compile(self.pattern)
except re.error as exc:
@@ -331,6 +359,19 @@ class CollectionQuery(Query):
clause = (' ' + joiner + ' ').join(clause_parts)
return clause, subvals
+ def __repr__(self):
+ return "{0.__class__.__name__}({0.subqueries})".format(self)
+
+ def __eq__(self, other):
+ return super(CollectionQuery, self).__eq__(other) and \
+ self.subqueries == other.subqueries
+
+ def __hash__(self):
+ """Since subqueries are mutable, this object should not be hashable.
+ However and for conveniencies purposes, it can be hashed.
+ """
+ return reduce(mul, map(hash, self.subqueries), 1)
+
class AnyFieldQuery(CollectionQuery):
"""A query that matches if a given FieldQuery subclass matches in
@@ -356,6 +397,17 @@ class AnyFieldQuery(CollectionQuery):
return True
return False
+ def __repr__(self):
+ return ("{0.__class__.__name__}({0.pattern!r}, {0.fields}, "
+ "{0.query_class.__name__})".format(self))
+
+ def __eq__(self, other):
+ return super(AnyFieldQuery, self).__eq__(other) and \
+ self.query_class == other.query_class
+
+ def __hash__(self):
+ return hash((self.pattern, tuple(self.fields), self.query_class))
+
class MutableCollectionQuery(CollectionQuery):
"""A collection query whose subqueries may be modified after the
@@ -590,6 +642,12 @@ class Sort(object):
"""
return False
+ def __hash__(self):
+ return 0
+
+ def __eq__(self, other):
+ return type(self) == type(other)
+
class MultipleSort(Sort):
"""Sort that encapsulates multiple sub-sorts.
@@ -651,6 +709,13 @@ class MultipleSort(Sort):
def __repr__(self):
return u'MultipleSort({0})'.format(repr(self.sorts))
+ def __hash__(self):
+ return hash(tuple(self.sorts))
+
+ def __eq__(self, other):
+ return super(MultipleSort, self).__eq__(other) and \
+ self.sorts == other.sorts
+
class FieldSort(Sort):
"""An abstract sort criterion that orders by a specific field (of
@@ -674,6 +739,14 @@ class FieldSort(Sort):
'+' if self.ascending else '-',
)
+ def __hash__(self):
+ return hash((self.field, self.ascending))
+
+ def __eq__(self, other):
+ return super(FieldSort, self).__eq__(other) and \
+ self.field == other.field and \
+ self.ascending == other.ascending
+
class FixedFieldSort(FieldSort):
"""Sort object to sort on a fixed field.
@@ -695,3 +768,15 @@ class NullSort(Sort):
"""No sorting. Leave results unsorted."""
def sort(items):
return items
+
+ def __nonzero__(self):
+ return self.__bool__()
+
+ def __bool__(self):
+ return False
+
+ def __eq__(self, other):
+ return type(self) == type(other) or other is None
+
+ def __hash__(self):
+ return 0
diff --git a/beets/dbcore/queryparse.py b/beets/dbcore/queryparse.py
index 6628bebf0..1dcf9c4b3 100644
--- a/beets/dbcore/queryparse.py
+++ b/beets/dbcore/queryparse.py
@@ -152,12 +152,14 @@ def sort_from_strings(model_cls, sort_parts):
"""Create a `Sort` from a list of sort criteria (strings).
"""
if not sort_parts:
- return query.NullSort()
+ sort = query.NullSort()
+ elif len(sort_parts) == 1:
+ sort = construct_sort_part(model_cls, sort_parts[0])
else:
sort = query.MultipleSort()
for part in sort_parts:
sort.add_sort(construct_sort_part(model_cls, part))
- return sort
+ return sort
def parse_sorted_query(model_cls, parts, prefixes={},
diff --git a/beets/library.py b/beets/library.py
index b9358ce6b..9f448227f 100644
--- a/beets/library.py
+++ b/beets/library.py
@@ -24,6 +24,7 @@ import unicodedata
import time
import re
from unidecode import unidecode
+import platform
from beets import logging
from beets.mediafile import MediaFile, MutagenError, UnreadableFileError
@@ -42,30 +43,55 @@ log = logging.getLogger('beets')
# Library-specific query types.
class PathQuery(dbcore.FieldQuery):
- """A query that matches all items under a given path."""
+ """A query that matches all items under a given path.
+
+ Matching can either be case-insensitive or case-sensitive. By
+ default, the behavior depends on the OS: case-insensitive on Windows
+ and case-sensitive otherwise.
+ """
escape_re = re.compile(r'[\\_%]')
escape_char = b'\\'
- def __init__(self, field, pattern, fast=True):
+ def __init__(self, field, pattern, fast=True, case_sensitive=None):
+ """Create a path query.
+
+ `case_sensitive` can be a bool or `None`, indicating that the
+ behavior should depend on the platform (the default).
+ """
super(PathQuery, self).__init__(field, pattern, fast)
+ # By default, the case sensitivity depends on the platform.
+ if case_sensitive is None:
+ case_sensitive = platform.system() != 'Windows'
+ self.case_sensitive = case_sensitive
+
+ # Use a normalized-case pattern for case-insensitive matches.
+ if not case_sensitive:
+ pattern = pattern.lower()
+
# Match the path as a single file.
self.file_path = util.bytestring_path(util.normpath(pattern))
# As a directory (prefix).
self.dir_path = util.bytestring_path(os.path.join(self.file_path, b''))
def match(self, item):
- return (item.path == self.file_path) or \
- item.path.startswith(self.dir_path)
+ path = item.path if self.case_sensitive else item.path.lower()
+ return (path == self.file_path) or path.startswith(self.dir_path)
def col_clause(self):
+ file_blob = buffer(self.file_path)
+
+ if self.case_sensitive:
+ dir_blob = buffer(self.dir_path)
+ return '({0} = ?) || (substr({0}, 1, ?) = ?)'.format(self.field), \
+ (file_blob, len(dir_blob), dir_blob)
+
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'%')
- file_blob = buffer(self.file_path)
+ dir_blob = buffer(dir_pattern + b'%')
return '({0} = ?) || ({0} LIKE ? ESCAPE ?)'.format(self.field), \
- (file_blob, dir_pattern, self.escape_char)
+ (file_blob, dir_blob, self.escape_char)
# Library-specific field types.
@@ -244,15 +270,15 @@ class LibModel(dbcore.Model):
def store(self):
super(LibModel, self).store()
- plugins.send('database_change', lib=self._db)
+ plugins.send('database_change', lib=self._db, model=self)
def remove(self):
super(LibModel, self).remove()
- plugins.send('database_change', lib=self._db)
+ plugins.send('database_change', lib=self._db, model=self)
def add(self, lib=None):
super(LibModel, self).add(lib)
- plugins.send('database_change', lib=self._db)
+ plugins.send('database_change', lib=self._db, model=self)
def __format__(self, spec):
if not spec:
@@ -393,6 +419,10 @@ class Item(LibModel):
_search_fields = ('artist', 'title', 'comments',
'album', 'albumartist', 'genre')
+ _types = {
+ 'data_source': types.STRING,
+ }
+
_media_fields = set(MediaFile.readable_fields()) \
.intersection(_fields.keys())
"""Set of item fields that are backed by `MediaFile` fields.
@@ -414,14 +444,13 @@ class Item(LibModel):
_sorts = {'artist': SmartArtistSort}
- _format_config_key = 'list_format_item'
+ _format_config_key = 'format_item'
@classmethod
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))
+ getters['filesize'] = Item.try_filesize # In bytes.
return getters
@classmethod
@@ -605,6 +634,17 @@ class Item(LibModel):
"""
return int(os.path.getmtime(syspath(self.path)))
+ def try_filesize(self):
+ """Get the size of the underlying file in bytes.
+
+ If the file is missing, return 0 (and log a warning).
+ """
+ try:
+ return os.path.getsize(syspath(self.path))
+ except (OSError, Exception) as exc:
+ log.warning(u'could not get filesize: {0}', exc)
+ return 0
+
# Model methods.
def remove(self, delete=False, with_album=True):
@@ -795,7 +835,8 @@ class Album(LibModel):
_search_fields = ('album', 'albumartist', 'genre')
_types = {
- 'path': PathType(),
+ 'path': PathType(),
+ 'data_source': types.STRING,
}
_sorts = {
@@ -836,7 +877,7 @@ class Album(LibModel):
"""List of keys that are set on an album's items.
"""
- _format_config_key = 'list_format_album'
+ _format_config_key = 'format_album'
@classmethod
def _getters(cls):
@@ -1081,11 +1122,12 @@ def parse_query_string(s, model_cls):
The string is split into components using shell-like syntax.
"""
+ assert isinstance(s, unicode), "Query is not unicode: {0!r}".format(s)
+
# A bug in Python < 2.7.3 prevents correct shlex splitting of
# Unicode strings.
# http://bugs.python.org/issue6988
- if isinstance(s, unicode):
- s = s.encode('utf8')
+ s = s.encode('utf8')
try:
parts = [p.decode('utf8') for p in shlex.split(s)]
except ValueError as exc:
@@ -1177,21 +1219,29 @@ class Library(dbcore.Database):
model_cls, query, sort
)
+ @staticmethod
+ def get_default_album_sort():
+ """Get a :class:`Sort` object for albums from the config option.
+ """
+ return dbcore.sort_from_strings(
+ Album, beets.config['sort_album'].as_str_seq())
+
+ @staticmethod
+ def get_default_item_sort():
+ """Get a :class:`Sort` object for items from the config option.
+ """
+ return dbcore.sort_from_strings(
+ Item, beets.config['sort_item'].as_str_seq())
+
def albums(self, query=None, sort=None):
"""Get :class:`Album` objects matching the query.
"""
- sort = sort or dbcore.sort_from_strings(
- Album, beets.config['sort_album'].as_str_seq()
- )
- return self._fetch(Album, query, sort)
+ return self._fetch(Album, query, sort or self.get_default_album_sort())
def items(self, query=None, sort=None):
"""Get :class:`Item` objects matching the query.
"""
- sort = sort or dbcore.sort_from_strings(
- Item, beets.config['sort_item'].as_str_seq()
- )
- return self._fetch(Item, query, sort)
+ return self._fetch(Item, query, sort or self.get_default_item_sort())
# Convenience accessors.
@@ -1300,11 +1350,11 @@ class DefaultTemplateFunctions(object):
return unidecode(s)
@staticmethod
- def tmpl_time(s, format):
+ def tmpl_time(s, fmt):
"""Format a time value using `strftime`.
"""
cur_fmt = beets.config['time_format'].get(unicode)
- return time.strftime(format, time.strptime(s, cur_fmt))
+ return time.strftime(fmt, time.strptime(s, cur_fmt))
def tmpl_aunique(self, keys=None, disam=None):
"""Generate a string that is guaranteed to be unique among all
diff --git a/beets/mediafile.py b/beets/mediafile.py
index 6c5d0a2c2..22899dee0 100644
--- a/beets/mediafile.py
+++ b/beets/mediafile.py
@@ -1246,18 +1246,31 @@ class DateItemField(MediaField):
class CoverArtField(MediaField):
"""A descriptor that provides access to the *raw image data* for the
- first image on a file. This is used for backwards compatibility: the
+ cover image on a file. This is used for backwards compatibility: the
full `ImageListField` provides richer `Image` objects.
+
+ When there are multiple images we try to pick the most likely to be a front
+ cover.
"""
def __init__(self):
pass
def __get__(self, mediafile, _):
- try:
- return mediafile.images[0].data
- except IndexError:
+ candidates = mediafile.images
+ if candidates:
+ return self.guess_cover_image(candidates).data
+ else:
return None
+ @staticmethod
+ def guess_cover_image(candidates):
+ if len(candidates) == 1:
+ return candidates[0]
+ try:
+ return next(c for c in candidates if c.type == ImageType.front)
+ except StopIteration:
+ return candidates[0]
+
def __set__(self, mediafile, data):
if data:
mediafile.images = [Image(data=data)]
diff --git a/beets/plugins.py b/beets/plugins.py
index bd285da2d..c642e9318 100755
--- a/beets/plugins.py
+++ b/beets/plugins.py
@@ -17,8 +17,8 @@
from __future__ import (division, absolute_import, print_function,
unicode_literals)
-import traceback
import inspect
+import traceback
import re
from collections import defaultdict
from functools import wraps
@@ -84,10 +84,8 @@ class BeetsPlugin(object):
self._log = log.getChild(self.name)
self._log.setLevel(logging.NOTSET) # Use `beets` logger level.
- if beets.config['verbose']:
- if not any(isinstance(f, PluginLogFilter)
- for f in self._log.filters):
- self._log.addFilter(PluginLogFilter(self))
+ if not any(isinstance(f, PluginLogFilter) for f in self._log.filters):
+ self._log.addFilter(PluginLogFilter(self))
def commands(self):
"""Should return a list of beets.ui.Subcommand objects for
@@ -103,26 +101,39 @@ class BeetsPlugin(object):
`self.import_stages`. Wrapping provides some bookkeeping for the
plugin: specifically, the logging level is adjusted to WARNING.
"""
- return [self._set_log_level(logging.WARNING, import_stage)
+ return [self._set_log_level_and_params(logging.WARNING, import_stage)
for import_stage in self.import_stages]
- def _set_log_level(self, log_level, func):
+ def _set_log_level_and_params(self, base_log_level, func):
"""Wrap `func` to temporarily set this plugin's logger level to
- `log_level` (and restore it after the function returns).
+ `base_log_level` + config options (and restore it to its previous
+ value after the function returns). Also determines which params may not
+ be sent for backwards-compatibility.
- The level is *not* adjusted when beets is in verbose
- mode---i.e., the plugin logger continues to delegate to the base
- beets logger.
+ Note that the log level value may not be NOTSET, e.g. if a plugin
+ import stage triggers an event that is listened this very same plugin.
"""
+ argspec = inspect.getargspec(func)
+
@wraps(func)
def wrapper(*args, **kwargs):
- if not beets.config['verbose']:
- old_log_level = self._log.level
- self._log.setLevel(log_level)
- result = func(*args, **kwargs)
- if not beets.config['verbose']:
+ old_log_level = self._log.level
+ verbosity = beets.config['verbose'].get(int)
+ log_level = max(logging.DEBUG, base_log_level - 10 * verbosity)
+ self._log.setLevel(log_level)
+ try:
+ try:
+ return func(*args, **kwargs)
+ except TypeError as exc:
+ if exc.args[0].startswith(func.__name__):
+ # caused by 'func' and not stuff internal to 'func'
+ kwargs = dict((arg, val) for arg, val in kwargs.items()
+ if arg in argspec.args)
+ return func(*args, **kwargs)
+ else:
+ raise
+ finally:
self._log.setLevel(old_log_level)
- return result
return wrapper
def queries(self):
@@ -181,36 +192,21 @@ class BeetsPlugin(object):
mediafile.MediaFile.add_field(name, descriptor)
library.Item._media_fields.add(name)
+ _raw_listeners = None
listeners = None
- @classmethod
- def register_listener(cls, event, func):
- """Add a function as a listener for the specified event. (An
- imperative alternative to the @listen decorator.)
+ def register_listener(self, event, func):
+ """Add a function as a listener for the specified event.
"""
- if cls.listeners is None:
+ wrapped_func = self._set_log_level_and_params(logging.WARNING, func)
+
+ cls = self.__class__
+ if cls.listeners is None or cls._raw_listeners is None:
+ cls._raw_listeners = defaultdict(list)
cls.listeners = defaultdict(list)
- if func not in cls.listeners[event]:
- cls.listeners[event].append(func)
-
- @classmethod
- def listen(cls, event):
- """Decorator that adds a function as an event handler for the
- specified event (as a string). The parameters passed to function
- will vary depending on what event occurred.
-
- The function should respond to named parameters.
- function(**kwargs) will trap all arguments in a dictionary.
- Example:
-
- >>> @MyPlugin.listen("imported")
- >>> def importListener(**kwargs):
- ... pass
- """
- def helper(func):
- cls.register_listener(event, func)
- return func
- return helper
+ if func not in cls._raw_listeners[event]:
+ cls._raw_listeners[event].append(func)
+ cls.listeners[event].append(wrapped_func)
template_funcs = None
template_fields = None
@@ -459,10 +455,7 @@ def send(event, **arguments):
log.debug(u'Sending event: {0}', event)
results = []
for handler in event_handlers()[event]:
- # Don't break legacy plugins if we want to pass more arguments
- argspec = inspect.getargspec(handler).args
- args = dict((k, v) for k, v in arguments.items() if k in argspec)
- result = handler(**args)
+ result = handler(**arguments)
if result is not None:
results.append(result)
return results
diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py
index 02a7a9478..35ee69e01 100644
--- a/beets/ui/__init__.py
+++ b/beets/ui/__init__.py
@@ -592,6 +592,119 @@ def show_model_changes(new, old=None, fields=None, always=False):
return bool(changes)
+class CommonOptionsParser(optparse.OptionParser, object):
+ """Offers a simple way to add common formatting options.
+
+ Options available include:
+ - matching albums instead of tracks: add_album_option()
+ - showing paths instead of items/albums: add_path_option()
+ - changing the format of displayed items/albums: add_format_option()
+
+ The last one can have several behaviors:
+ - against a special target
+ - with a certain format
+ - autodetected target with the album option
+
+ Each method is fully documented in the related method.
+ """
+ def __init__(self, *args, **kwargs):
+ super(CommonOptionsParser, self).__init__(*args, **kwargs)
+ self._album_flags = False
+ # this serves both as an indicator that we offer the feature AND allows
+ # us to check whether it has been specified on the CLI - bypassing the
+ # fact that arguments may be in any order
+
+ def add_album_option(self, flags=('-a', '--album')):
+ """Add a -a/--album option to match albums instead of tracks.
+
+ If used then the format option can auto-detect whether we're setting
+ the format for items or albums.
+ Sets the album property on the options extracted from the CLI.
+ """
+ album = optparse.Option(*flags, action='store_true',
+ help='match albums instead of tracks')
+ self.add_option(album)
+ self._album_flags = set(flags)
+
+ def _set_format(self, option, opt_str, value, parser, target=None,
+ fmt=None, store_true=False):
+ """Internal callback that sets the correct format while parsing CLI
+ arguments.
+ """
+ if store_true:
+ setattr(parser.values, option.dest, True)
+
+ value = fmt or value and unicode(value) or ''
+ parser.values.format = value
+ if target:
+ config[target._format_config_key].set(value)
+ else:
+ if self._album_flags:
+ if parser.values.album:
+ target = library.Album
+ else:
+ # the option is either missing either not parsed yet
+ if self._album_flags & set(parser.rargs):
+ target = library.Album
+ else:
+ target = library.Item
+ config[target._format_config_key].set(value)
+ else:
+ config[library.Item._format_config_key].set(value)
+ config[library.Album._format_config_key].set(value)
+
+ def add_path_option(self, flags=('-p', '--path')):
+ """Add a -p/--path option to display the path instead of the default
+ format.
+
+ By default this affects both items and albums. If add_album_option()
+ is used then the target will be autodetected.
+
+ Sets the format property to u'$path' on the options extracted from the
+ CLI.
+ """
+ path = optparse.Option(*flags, nargs=0, action='callback',
+ callback=self._set_format,
+ callback_kwargs={'fmt': '$path',
+ 'store_true': True},
+ help='print paths for matched items or albums')
+ self.add_option(path)
+
+ def add_format_option(self, flags=('-f', '--format'), target=None):
+ """Add -f/--format option to print some LibModel instances with a
+ custom format.
+
+ `target` is optional and can be one of ``library.Item``, 'item',
+ ``library.Album`` and 'album'.
+
+ Several behaviors are available:
+ - if `target` is given then the format is only applied to that
+ LibModel
+ - if the album option is used then the target will be autodetected
+ - otherwise the format is applied to both items and albums.
+
+ Sets the format property on the options extracted from the CLI.
+ """
+ kwargs = {}
+ if target:
+ if isinstance(target, basestring):
+ target = {'item': library.Item,
+ 'album': library.Album}[target]
+ kwargs['target'] = target
+
+ opt = optparse.Option(*flags, action='callback',
+ callback=self._set_format,
+ callback_kwargs=kwargs,
+ help='print with custom format')
+ self.add_option(opt)
+
+ def add_all_common_options(self):
+ """Add album, path and format options.
+ """
+ self.add_album_option()
+ self.add_path_option()
+ self.add_format_option()
+
# Subcommand parsing infrastructure.
#
# This is a fairly generic subcommand parser for optparse. It is
@@ -600,6 +713,7 @@ def show_model_changes(new, old=None, fields=None, always=False):
# There you will also find a better description of the code and a more
# succinct example program.
+
class Subcommand(object):
"""A subcommand of a root command-line application that may be
invoked by a SubcommandOptionParser.
@@ -609,10 +723,10 @@ class Subcommand(object):
the subcommand; aliases are alternate names. parser is an
OptionParser responsible for parsing the subcommand's options.
help is a short description of the command. If no parser is
- given, it defaults to a new, empty OptionParser.
+ given, it defaults to a new, empty CommonOptionsParser.
"""
self.name = name
- self.parser = parser or optparse.OptionParser()
+ self.parser = parser or CommonOptionsParser()
self.aliases = aliases
self.help = help
self.hide = hide
@@ -635,7 +749,7 @@ class Subcommand(object):
root_parser.get_prog_name().decode('utf8'), self.name)
-class SubcommandsOptionParser(optparse.OptionParser):
+class SubcommandsOptionParser(CommonOptionsParser):
"""A variant of OptionParser that parses subcommands and their
arguments.
"""
@@ -653,7 +767,7 @@ class SubcommandsOptionParser(optparse.OptionParser):
kwargs['add_help_option'] = False
# Super constructor.
- optparse.OptionParser.__init__(self, *args, **kwargs)
+ super(SubcommandsOptionParser, self).__init__(*args, **kwargs)
# Our root parser needs to stop on the first unrecognized argument.
self.disable_interspersed_args()
@@ -670,7 +784,7 @@ class SubcommandsOptionParser(optparse.OptionParser):
# Add the list of subcommands to the help message.
def format_help(self, formatter=None):
# Get the original help message, to which we will append.
- out = optparse.OptionParser.format_help(self, formatter)
+ out = super(SubcommandsOptionParser, self).format_help(formatter)
if formatter is None:
formatter = self.formatter
@@ -807,6 +921,7 @@ def _load_plugins(config):
"""
paths = config['pluginpath'].get(confit.StrSeq(split=False))
paths = map(util.normpath, paths)
+ log.debug('plugin paths: {0}', util.displayable_path(paths))
import beetsplug
beetsplug.__path__ = paths + beetsplug.__path__
@@ -858,7 +973,7 @@ def _configure(options):
config.set_args(options)
# Configure the logger.
- if config['verbose'].get(bool):
+ if config['verbose'].get(int):
log.setLevel(logging.DEBUG)
else:
log.setLevel(logging.INFO)
@@ -871,6 +986,17 @@ def _configure(options):
u'See documentation for more info.')
config['ui']['color'].set(config['color'].get(bool))
+ # Compatibility from list_format_{item,album} to format_{item,album}
+ for elem in ('item', 'album'):
+ old_key = 'list_format_{0}'.format(elem)
+ if config[old_key].exists():
+ new_key = 'format_{0}'.format(elem)
+ log.warning('Warning: configuration uses "{0}" which is deprecated'
+ ' in favor of "{1}" now that it affects all commands. '
+ 'See changelog & documentation.'.format(old_key,
+ new_key))
+ config[new_key].set(config[old_key])
+
config_path = config.user_config_path()
if os.path.isfile(config_path):
log.debug(u'user configuration: {0}',
@@ -913,11 +1039,13 @@ def _raw_main(args, lib=None):
handling.
"""
parser = SubcommandsOptionParser()
+ parser.add_format_option(flags=('--format-item',), target=library.Item)
+ parser.add_format_option(flags=('--format-album',), target=library.Album)
parser.add_option('-l', '--library', dest='library',
help='library database file to use')
parser.add_option('-d', '--directory', dest='directory',
help="destination music directory")
- parser.add_option('-v', '--verbose', dest='verbose', action='store_true',
+ parser.add_option('-v', '--verbose', dest='verbose', action='count',
help='print debugging information')
parser.add_option('-c', '--config', dest='config',
help='path to configuration file')
diff --git a/beets/ui/commands.py b/beets/ui/commands.py
index 32201f58d..73bcc7b0f 100644
--- a/beets/ui/commands.py
+++ b/beets/ui/commands.py
@@ -20,9 +20,7 @@ from __future__ import (division, absolute_import, print_function,
unicode_literals)
import os
-import platform
import re
-import shlex
import beets
from beets import ui
@@ -96,11 +94,15 @@ def fields_func(lib, opts, args):
_print_rows(plugin_fields)
print("Item fields:")
- _print_rows(library.Item._fields.keys())
+ _print_rows(library.Item._fields.keys() +
+ library.Item._getters().keys() +
+ library.Item._types.keys())
_show_plugin_fields(False)
print("\nAlbum fields:")
- _print_rows(library.Album._fields.keys())
+ _print_rows(library.Album._fields.keys() +
+ library.Album._getters().keys() +
+ library.Album._types.keys())
_show_plugin_fields(True)
@@ -424,8 +426,6 @@ def summarize_items(items, singleton):
this is an album or single-item import (if the latter, them `items`
should only have one element).
"""
- assert items, "summarizing zero items"
-
summary_parts = []
if not singleton:
summary_parts.append("{0} items".format(len(items)))
@@ -437,16 +437,18 @@ def summarize_items(items, singleton):
# A single format.
summary_parts.append(items[0].format)
else:
- # Enumerate all the formats.
- for format, count in format_counts.iteritems():
- summary_parts.append('{0} {1}'.format(format, count))
+ # Enumerate all the formats by decreasing frequencies:
+ for fmt, count in sorted(format_counts.items(),
+ key=lambda (f, c): (-c, f)):
+ summary_parts.append('{0} {1}'.format(fmt, count))
- 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))
+ if items:
+ 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)
@@ -776,6 +778,16 @@ class TerminalImportSession(importer.ImportSession):
log.warn(u"This {0} is already in the library!",
("album" if task.is_album else "item"))
+ # skip empty albums (coming from a previous failed import session)
+ if task.is_album:
+ real_duplicates = [dup for dup in found_duplicates if dup.items()]
+ if not real_duplicates:
+ log.info("All duplicates are empty, we ignore them")
+ task.should_remove_duplicates = True
+ return
+ else:
+ real_duplicates = found_duplicates
+
if config['import']['quiet']:
# In quiet mode, don't prompt -- just skip.
log.info(u'Skipping.')
@@ -783,11 +795,17 @@ class TerminalImportSession(importer.ImportSession):
else:
# Print some detail about the existing and new items so the
# user can make an informed decision.
- for duplicate in found_duplicates:
+ for duplicate in real_duplicates:
print("Old: " + summarize_items(
list(duplicate.items()) if task.is_album else [duplicate],
not task.is_album,
))
+
+ if real_duplicates != found_duplicates: # there's empty albums
+ count = len(found_duplicates) - len(real_duplicates)
+ print("Old: {0} empty album{1}".format(
+ count, "s" if count > 1 else ""))
+
print("New: " + summarize_items(
task.imported_items(),
not task.is_album,
@@ -955,7 +973,7 @@ default_commands.append(import_cmd)
# list: Query and show library contents.
-def list_items(lib, query, album, fmt):
+def list_items(lib, query, album, fmt=''):
"""Print out items in lib matching query. If album, then search for
albums instead of single items.
"""
@@ -968,23 +986,11 @@ def list_items(lib, query, album, fmt):
def list_func(lib, opts, args):
- fmt = '$path' if opts.path else opts.format
- list_items(lib, decargs(args), opts.album, fmt)
+ list_items(lib, decargs(args), opts.album)
list_cmd = ui.Subcommand('list', help='query the library', aliases=('ls',))
-list_cmd.parser.add_option(
- '-a', '--album', action='store_true',
- help='show matching albums instead of tracks'
-)
-list_cmd.parser.add_option(
- '-p', '--path', action='store_true',
- help='print paths for matched items or albums'
-)
-list_cmd.parser.add_option(
- '-f', '--format', action='store',
- help='print with custom format', default=''
-)
+list_cmd.parser.add_all_common_options()
list_cmd.func = list_func
default_commands.append(list_cmd)
@@ -1085,10 +1091,8 @@ def update_func(lib, opts, args):
update_cmd = ui.Subcommand(
'update', help='update the library', aliases=('upd', 'up',)
)
-update_cmd.parser.add_option(
- '-a', '--album', action='store_true',
- help='match albums instead of tracks'
-)
+update_cmd.parser.add_album_option()
+update_cmd.parser.add_format_option()
update_cmd.parser.add_option(
'-M', '--nomove', action='store_false', default=True, dest='move',
help="don't move files in library"
@@ -1097,10 +1101,6 @@ update_cmd.parser.add_option(
'-p', '--pretend', action='store_true',
help="show all changes but do nothing"
)
-update_cmd.parser.add_option(
- '-f', '--format', action='store',
- help='print with custom format', default=''
-)
update_cmd.func = update_func
default_commands.append(update_cmd)
@@ -1149,10 +1149,7 @@ remove_cmd.parser.add_option(
"-d", "--delete", action="store_true",
help="also remove files from disk"
)
-remove_cmd.parser.add_option(
- '-a', '--album', action='store_true',
- help='match albums instead of tracks'
-)
+remove_cmd.parser.add_album_option()
remove_cmd.func = remove_func
default_commands.append(remove_cmd)
@@ -1346,18 +1343,12 @@ modify_cmd.parser.add_option(
'-W', '--nowrite', action='store_false', dest='write',
help="don't write metadata (opposite of -w)"
)
-modify_cmd.parser.add_option(
- '-a', '--album', action='store_true',
- help='modify whole albums instead of tracks'
-)
+modify_cmd.parser.add_album_option()
+modify_cmd.parser.add_format_option(target='item')
modify_cmd.parser.add_option(
'-y', '--yes', action='store_true',
help='skip confirmation'
)
-modify_cmd.parser.add_option(
- '-f', '--format', action='store',
- help='print with custom format', default=''
-)
modify_cmd.func = modify_func
default_commands.append(modify_cmd)
@@ -1403,10 +1394,7 @@ move_cmd.parser.add_option(
'-c', '--copy', default=False, action='store_true',
help='copy instead of moving'
)
-move_cmd.parser.add_option(
- '-a', '--album', default=False, action='store_true',
- help='match whole albums instead of tracks'
-)
+move_cmd.parser.add_album_option()
move_cmd.func = move_func
default_commands.append(move_cmd)
@@ -1495,30 +1483,14 @@ def config_edit():
"""
path = config.user_config_path()
- if 'EDITOR' in os.environ:
- editor = os.environ['EDITOR'].encode('utf8')
- try:
- editor = [e.decode('utf8') for e in shlex.split(editor)]
- except ValueError: # Malformed shell tokens.
- editor = [editor]
- args = editor + [path]
- args.insert(1, args[0])
- elif platform.system() == 'Darwin':
- args = ['open', 'open', '-n', path]
- elif platform.system() == 'Windows':
- # On windows we can execute arbitrary files. The os will
- # take care of starting an appropriate application
- args = [path, path]
- else:
- # Assume Unix
- args = ['xdg-open', 'xdg-open', path]
-
+ editor = os.environ.get('EDITOR')
try:
- os.execlp(*args)
- except OSError:
- raise ui.UserError("Could not edit configuration. Please "
- "set the EDITOR environment variable.")
-
+ util.interactive_open(path, editor)
+ except OSError as exc:
+ message = "Could not edit configuration: {0}".format(exc)
+ if not editor:
+ message += ". Please set the EDITOR environment variable"
+ raise ui.UserError(message)
config_cmd = ui.Subcommand('config',
help='show or edit the user configuration')
diff --git a/beets/util/__init__.py b/beets/util/__init__.py
index 01a0257b0..a3b57eea6 100644
--- a/beets/util/__init__.py
+++ b/beets/util/__init__.py
@@ -26,6 +26,7 @@ from collections import defaultdict
import traceback
import subprocess
import platform
+import shlex
MAX_FILENAME_LENGTH = 200
@@ -623,7 +624,7 @@ def cpu_count():
num = 0
elif sys.platform == b'darwin':
try:
- num = int(command_output(['sysctl', '-n', 'hw.ncpu']))
+ num = int(command_output([b'sysctl', b'-n', b'hw.ncpu']))
except ValueError:
num = 0
else:
@@ -640,8 +641,8 @@ def cpu_count():
def command_output(cmd, shell=False):
"""Runs the command and returns its output after it has exited.
- ``cmd`` is a list of arguments starting with the command names. If
- ``shell`` is true, ``cmd`` is assumed to be a string and passed to a
+ ``cmd`` is a list of byte string arguments starting with the command names.
+ If ``shell`` is true, ``cmd`` is assumed to be a string and passed to a
shell to execute.
If the process exits with a non-zero return code
@@ -663,7 +664,7 @@ def command_output(cmd, shell=False):
if proc.returncode:
raise subprocess.CalledProcessError(
returncode=proc.returncode,
- cmd=' '.join(cmd),
+ cmd=b' '.join(cmd),
)
return stdout
@@ -683,3 +684,40 @@ def max_filename_length(path, limit=MAX_FILENAME_LENGTH):
return min(res[9], limit)
else:
return limit
+
+
+def open_anything():
+ """Return the system command that dispatches execution to the correct
+ program.
+ """
+ sys_name = platform.system()
+ if sys_name == 'Darwin':
+ base_cmd = 'open'
+ elif sys_name == 'Windows':
+ base_cmd = 'start'
+ else: # Assume Unix
+ base_cmd = 'xdg-open'
+ return base_cmd
+
+
+def interactive_open(target, command=None):
+ """Open `target` file with `command` or, in not available, ask the OS to
+ deal with it.
+
+ The executed program will have stdin, stdout and stderr.
+ OSError may be raised, it is left to the caller to catch them.
+ """
+ if command:
+ command = command.encode('utf8')
+ try:
+ command = [c.decode('utf8')
+ for c in shlex.split(command)]
+ except ValueError: # Malformed shell tokens.
+ command = [command]
+ command.insert(0, command[0]) # for argv[0]
+ else:
+ base_cmd = open_anything()
+ command = [base_cmd, base_cmd]
+
+ command.append(target)
+ return os.execlp(*command)
diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py
index e33b2decf..983a9dd15 100644
--- a/beets/util/artresizer.py
+++ b/beets/util/artresizer.py
@@ -91,8 +91,8 @@ def im_resize(maxwidth, path_in, path_out=None):
# compatibility.
try:
util.command_output([
- 'convert', util.syspath(path_in),
- '-resize', '{0}x^>'.format(maxwidth), path_out
+ b'convert', util.syspath(path_in),
+ b'-resize', b'{0}x^>'.format(maxwidth), path_out
])
except subprocess.CalledProcessError:
log.warn(u'artresizer: IM convert failed for {0}',
@@ -227,7 +227,7 @@ def has_IM():
"""Return Image Magick version or None if it is unavailable
Try invoking ImageMagick's "convert"."""
try:
- out = util.command_output(['identify', '--version'])
+ out = util.command_output([b'identify', b'--version'])
if 'imagemagick' in out.lower():
pattern = r".+ (\d+)\.(\d+)\.(\d+).*"
diff --git a/beets/util/confit.py b/beets/util/confit.py
index c30e52c5d..110d00001 100644
--- a/beets/util/confit.py
+++ b/beets/util/confit.py
@@ -602,11 +602,11 @@ class Dumper(yaml.SafeDumper):
for item_key, item_value in mapping:
node_key = self.represent_data(item_key)
node_value = self.represent_data(item_value)
- if not (isinstance(node_key, yaml.ScalarNode)
- and not node_key.style):
+ if not (isinstance(node_key, yaml.ScalarNode) and
+ not node_key.style):
best_style = False
- if not (isinstance(node_value, yaml.ScalarNode)
- and not node_value.style):
+ if not (isinstance(node_value, yaml.ScalarNode) and
+ not node_value.style):
best_style = False
value.append((node_key, node_value))
if flow_style is None:
diff --git a/beets/util/functemplate.py b/beets/util/functemplate.py
index f1066761d..3d5b0e871 100644
--- a/beets/util/functemplate.py
+++ b/beets/util/functemplate.py
@@ -309,8 +309,8 @@ class Parser(object):
# A non-special character. Skip to the next special
# character, treating the interstice as literal text.
next_pos = (
- self.special_char_re.search(self.string[self.pos:]).start()
- + self.pos
+ self.special_char_re.search(
+ self.string[self.pos:]).start() + self.pos
)
text_parts.append(self.string[self.pos:next_pos])
self.pos = next_pos
diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py
index 928f90479..8a83c0002 100644
--- a/beetsplug/chroma.py
+++ b/beetsplug/chroma.py
@@ -136,6 +136,7 @@ class AcoustidPlugin(plugins.BeetsPlugin):
if self.config['auto']:
self.register_listener('import_task_start', self.fingerprint_task)
+ self.register_listener('import_task_apply', apply_acoustid_metadata)
def fingerprint_task(self, task, session):
return fingerprint_task(self._log, task, session)
@@ -211,7 +212,6 @@ def fingerprint_task(log, task, session):
acoustid_match(log, item.path)
-@AcoustidPlugin.listen('import_task_apply')
def apply_acoustid_metadata(task, session):
"""Apply Acoustid metadata (fingerprint and ID) to the task's items.
"""
diff --git a/beetsplug/convert.py b/beetsplug/convert.py
index 2f49b5ef8..0cce93f7a 100644
--- a/beetsplug/convert.py
+++ b/beetsplug/convert.py
@@ -49,25 +49,25 @@ def replace_ext(path, ext):
return os.path.splitext(path)[0] + b'.' + ext
-def get_format(format=None):
+def get_format(fmt=None):
"""Return the command tempate and the extension from the config.
"""
- if not format:
- format = config['convert']['format'].get(unicode).lower()
- format = ALIASES.get(format, format)
+ if not fmt:
+ fmt = config['convert']['format'].get(unicode).lower()
+ fmt = ALIASES.get(fmt, fmt)
try:
- format_info = config['convert']['formats'][format].get(dict)
+ format_info = config['convert']['formats'][fmt].get(dict)
command = format_info['command']
extension = format_info['extension']
except KeyError:
raise ui.UserError(
u'convert: format {0} needs "command" and "extension" fields'
- .format(format)
+ .format(fmt)
)
except ConfigTypeError:
- command = config['convert']['formats'][format].get(bytes)
- extension = format
+ command = config['convert']['formats'][fmt].get(bytes)
+ extension = fmt
# Convenience and backwards-compatibility shortcuts.
keys = config['convert'].keys()
@@ -84,7 +84,7 @@ def get_format(format=None):
return (command.encode('utf8'), extension.encode('utf8'))
-def should_transcode(item, format):
+def should_transcode(item, fmt):
"""Determine whether the item should be transcoded as part of
conversion (i.e., its bitrate is high or it has the wrong format).
"""
@@ -92,7 +92,7 @@ def should_transcode(item, format):
not (item.format.lower() in LOSSLESS_FORMATS):
return False
maxbr = config['convert']['max_bitrate'].get(int)
- return format.lower() != item.format.lower() or \
+ return fmt.lower() != item.format.lower() or \
item.bitrate >= 1000 * maxbr
@@ -139,8 +139,6 @@ class ConvertPlugin(BeetsPlugin):
cmd = ui.Subcommand('convert', help='convert to external location')
cmd.parser.add_option('-p', '--pretend', action='store_true',
help='show actions but do nothing')
- cmd.parser.add_option('-a', '--album', action='store_true',
- help='choose albums instead of tracks')
cmd.parser.add_option('-t', '--threads', action='store', type='int',
help='change the number of threads, \
defaults to maximum available processors')
@@ -148,11 +146,12 @@ class ConvertPlugin(BeetsPlugin):
dest='keep_new', help='keep only the converted \
and move the old files')
cmd.parser.add_option('-d', '--dest', action='store',
- help='set the destination directory')
+ help='set the target format of the tracks')
cmd.parser.add_option('-f', '--format', action='store', dest='format',
help='set the destination directory')
cmd.parser.add_option('-y', '--yes', action='store_true', dest='yes',
help='do not ask for confirmation')
+ cmd.parser.add_album_option()
cmd.func = self.convert_func
return [cmd]
@@ -209,9 +208,9 @@ class ConvertPlugin(BeetsPlugin):
self._log.info(u'Finished encoding {0}',
util.displayable_path(source))
- def convert_item(self, dest_dir, keep_new, path_formats, format,
+ def convert_item(self, dest_dir, keep_new, path_formats, fmt,
pretend=False):
- command, ext = get_format(format)
+ command, ext = get_format(fmt)
item, original, converted = None, None, None
while True:
item = yield (item, original, converted)
@@ -224,11 +223,11 @@ class ConvertPlugin(BeetsPlugin):
if keep_new:
original = dest
converted = item.path
- if should_transcode(item, format):
+ if should_transcode(item, fmt):
converted = replace_ext(converted, ext)
else:
original = item.path
- if should_transcode(item, format):
+ if should_transcode(item, fmt):
dest = replace_ext(dest, ext)
converted = dest
@@ -254,7 +253,7 @@ class ConvertPlugin(BeetsPlugin):
util.displayable_path(original))
util.move(item.path, original)
- if should_transcode(item, format):
+ if should_transcode(item, fmt):
try:
self.encode(command, original, converted, pretend)
except subprocess.CalledProcessError:
@@ -359,7 +358,7 @@ class ConvertPlugin(BeetsPlugin):
self.config['pretend'].get(bool)
if not pretend:
- ui.commands.list_items(lib, ui.decargs(args), opts.album, '')
+ ui.commands.list_items(lib, ui.decargs(args), opts.album)
if not (opts.yes or ui.input_yn("Convert? (Y/n)")):
return
@@ -386,8 +385,8 @@ class ConvertPlugin(BeetsPlugin):
"""Transcode a file automatically after it is imported into the
library.
"""
- format = self.config['format'].get(unicode).lower()
- if should_transcode(item, format):
+ fmt = self.config['format'].get(unicode).lower()
+ if should_transcode(item, fmt):
command, ext = get_format()
fd, dest = tempfile.mkstemp('.' + ext)
os.close(fd)
diff --git a/beetsplug/cue.py b/beetsplug/cue.py
new file mode 100644
index 000000000..25205f8e8
--- /dev/null
+++ b/beetsplug/cue.py
@@ -0,0 +1,54 @@
+# Copyright 2015 Bruno Cauet
+# Split an album-file in tracks thanks a cue file
+
+import subprocess
+from os import path
+from glob import glob
+
+from beets.util import command_output, displayable_path
+from beets.plugins import BeetsPlugin
+from beets.autotag import TrackInfo
+
+
+class CuePlugin(BeetsPlugin):
+ def __init__(self):
+ super(CuePlugin, self).__init__()
+ # this does not seem supported by shnsplit
+ self.config.add({
+ 'keep_before': .1,
+ 'keep_after': .9,
+ })
+
+ # self.register_listener('import_task_start', self.look_for_cues)
+
+ def candidates(self, items, artist, album, va_likely):
+ import pdb
+ pdb.set_trace()
+
+ def item_candidates(self, item, artist, album):
+ dir = path.dirname(item.path)
+ cues = glob.glob(path.join(dir, "*.cue"))
+ if not cues:
+ return
+ if len(cues) > 1:
+ self._log.info(u"Found multiple cue files doing nothing: {0}",
+ map(displayable_path, cues))
+
+ cue_file = cues[0]
+ self._log.info("Found {} for {}", displayable_path(cue_file), item)
+
+ try:
+ # careful: will ask for input in case of conflicts
+ command_output(['shnsplit', '-f', cue_file, item.path])
+ except (subprocess.CalledProcessError, OSError):
+ self._log.exception(u'shnsplit execution failed')
+ return
+
+ tracks = glob(path.join(dir, "*.wav"))
+ self._log.info("Generated {0} tracks", len(tracks))
+ for t in tracks:
+ title = "dunno lol"
+ track_id = "wtf"
+ index = int(path.basename(t)[len("split-track"):-len(".wav")])
+ yield TrackInfo(title, track_id, index=index, artist=artist)
+ # generate TrackInfo instances
diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py
index 267041bcc..1f0070d93 100644
--- a/beetsplug/discogs.py
+++ b/beetsplug/discogs.py
@@ -32,6 +32,7 @@ import time
import json
import socket
import httplib
+import os
# Silence spurious INFO log lines generated by urllib3.
@@ -58,7 +59,7 @@ class DiscogsPlugin(BeetsPlugin):
self.discogs_client = None
self.register_listener('import_begin', self.setup)
- def setup(self):
+ def setup(self, session=None):
"""Create the `discogs_client` field. Authenticate if necessary.
"""
c_key = self.config['apikey'].get(unicode)
@@ -78,6 +79,12 @@ class DiscogsPlugin(BeetsPlugin):
self.discogs_client = Client(USER_AGENT, c_key, c_secret,
token, secret)
+ def reset_auth(self):
+ """Delete toke file & redo the auth steps.
+ """
+ os.remove(self._tokenfile())
+ self.setup()
+
def _tokenfile(self):
"""Get the path to the JSON file for storing the OAuth token.
"""
@@ -130,7 +137,11 @@ class DiscogsPlugin(BeetsPlugin):
return self.get_albums(query)
except DiscogsAPIError as e:
self._log.debug(u'API Error: {0} (query: {1})', e, query)
- return []
+ if e.status_code == 401:
+ self.reset_auth()
+ return self.candidates(items, artist, album, va_likely)
+ else:
+ return []
except CONNECTION_ERRORS as e:
self._log.debug(u'HTTP Connection Error: {0}', e)
return []
@@ -156,8 +167,11 @@ class DiscogsPlugin(BeetsPlugin):
try:
getattr(result, 'title')
except DiscogsAPIError as e:
- if e.message != '404 Not Found':
+ if e.status_code != 404:
self._log.debug(u'API Error: {0} (query: {1})', e, result._uri)
+ if e.status_code == 401:
+ self.reset_auth()
+ return self.album_for_id(album_id)
return None
except CONNECTION_ERRORS as e:
self._log.debug(u'HTTP Connection Error: {0}', e)
@@ -178,7 +192,13 @@ class DiscogsPlugin(BeetsPlugin):
# Strip medium information from query, Things like "CD1" and "disk 1"
# can also negate an otherwise positive result.
query = re.sub(r'(?i)\b(CD|disc)\s*\d+', '', query)
- releases = self.discogs_client.search(query, type='release').page(1)
+ try:
+ releases = self.discogs_client.search(query,
+ type='release').page(1)
+ except CONNECTION_ERRORS as exc:
+ self._log.debug("Communication error while searching for {0!r}: "
+ "{1}".format(query, exc))
+ return []
return [self.get_album_info(release) for release in releases[:5]]
def get_album_info(self, result):
diff --git a/beetsplug/duplicates.py b/beetsplug/duplicates.py
index fb697922b..1739d3530 100644
--- a/beetsplug/duplicates.py
+++ b/beetsplug/duplicates.py
@@ -125,17 +125,6 @@ class DuplicatesPlugin(BeetsPlugin):
self._command = Subcommand('duplicates',
help=__doc__,
aliases=['dup'])
-
- self._command.parser.add_option('-f', '--format', dest='format',
- action='store', type='string',
- help='print with custom format',
- metavar='FMT', default='')
-
- self._command.parser.add_option('-a', '--album', dest='album',
- action='store_true',
- help='show duplicate albums instead of'
- ' tracks')
-
self._command.parser.add_option('-c', '--count', dest='count',
action='store_true',
help='show duplicate counts')
@@ -168,15 +157,11 @@ class DuplicatesPlugin(BeetsPlugin):
action='store', metavar='DEST',
help='copy items to dest')
- self._command.parser.add_option('-p', '--path', dest='path',
- action='store_true',
- help='print paths for matched items or'
- ' albums')
-
self._command.parser.add_option('-t', '--tag', dest='tag',
action='store',
help='tag matched items with \'k=v\''
' attribute')
+ self._command.parser.add_all_common_options()
def commands(self):
diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py
index c8d45f3b0..753bfb5c3 100644
--- a/beetsplug/echonest.py
+++ b/beetsplug/echonest.py
@@ -486,10 +486,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin):
'-t', '--threshold', dest='threshold', action='store',
type='float', default=0.15, help='Set difference threshold'
)
- sim_cmd.parser.add_option(
- '-f', '--format', action='store', default='${difference}: ${path}',
- help='print with custom format'
- )
+ sim_cmd.parser.add_format_option()
def sim_func(lib, opts, args):
self.config.set_args(opts)
diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py
index 774782924..9117201e8 100644
--- a/beetsplug/embedart.py
+++ b/beetsplug/embedart.py
@@ -26,7 +26,7 @@ from beets.plugins import BeetsPlugin
from beets import mediafile
from beets import ui
from beets.ui import decargs
-from beets.util import syspath, normpath, displayable_path
+from beets.util import syspath, normpath, displayable_path, bytestring_path
from beets.util.artresizer import ArtResizer
from beets import config
@@ -101,7 +101,8 @@ class EmbedCoverArtPlugin(BeetsPlugin):
self.extract_first(normpath(opts.outpath),
lib.items(decargs(args)))
else:
- filename = opts.filename or config['art_filename'].get()
+ filename = bytestring_path(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")
@@ -195,13 +196,13 @@ class EmbedCoverArtPlugin(BeetsPlugin):
# Converting images to grayscale tends to minimize the weight
# of colors in the diff score.
convert_proc = subprocess.Popen(
- ['convert', syspath(imagepath), syspath(art),
- '-colorspace', 'gray', 'MIFF:-'],
+ [b'convert', syspath(imagepath), syspath(art),
+ b'-colorspace', b'gray', b'MIFF:-'],
stdout=subprocess.PIPE,
close_fds=not is_windows,
)
compare_proc = subprocess.Popen(
- ['compare', '-metric', 'PHASH', '-', 'null:'],
+ [b'compare', b'-metric', b'PHASH', b'-', b'null:'],
stdin=convert_proc.stdout,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
@@ -265,7 +266,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
self._log.warning(u'Unknown image type in {0}.',
displayable_path(item.path))
return
- outpath += '.' + ext
+ outpath += b'.' + ext
self._log.info(u'Extracting album art from: {0} to: {1}',
item, displayable_path(outpath))
diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py
index 1896552bf..7db05583b 100644
--- a/beetsplug/fetchart.py
+++ b/beetsplug/fetchart.py
@@ -142,6 +142,8 @@ class ITunesStore(ArtSource):
def get(self, album):
"""Return art URL from iTunes Store given an album title.
"""
+ if not (album.albumartist and album.album):
+ return
search_string = (album.albumartist + ' ' + album.album).encode('utf-8')
try:
# Isolate bugs in the iTunes library while searching.
diff --git a/beetsplug/fromfilename.py b/beetsplug/fromfilename.py
index 169c02ff6..dc040a0a2 100644
--- a/beetsplug/fromfilename.py
+++ b/beetsplug/fromfilename.py
@@ -140,10 +140,11 @@ def apply_matches(d):
# Plugin structure and hook into import process.
class FromFilenamePlugin(plugins.BeetsPlugin):
- pass
+ def __init__(self):
+ super(FromFilenamePlugin, self).__init__()
+ self.register_listener('import_task_start', filename_task)
-@FromFilenamePlugin.listen('import_task_start')
def filename_task(task, session):
"""Examine each item in the task to see if we can extract a title
from the filename. Try to match all filenames to a number of
diff --git a/beetsplug/info.py b/beetsplug/info.py
index 7a5a47b84..82bcbe965 100644
--- a/beetsplug/info.py
+++ b/beetsplug/info.py
@@ -145,7 +145,7 @@ class InfoPlugin(BeetsPlugin):
for data_emitter in data_collector(lib, ui.decargs(args)):
try:
data = data_emitter()
- except mediafile.UnreadableFileError as ex:
+ except (mediafile.UnreadableFileError, IOError) as ex:
self._log.error(u'cannot read file: {0}', ex)
continue
diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py
index e65593730..ab0e22be9 100644
--- a/beetsplug/lastgenre/__init__.py
+++ b/beetsplug/lastgenre/__init__.py
@@ -26,6 +26,7 @@ https://gist.github.com/1241307
import pylast
import os
import yaml
+import traceback
from beets import plugins
from beets import ui
@@ -391,17 +392,22 @@ class LastGenrePlugin(plugins.BeetsPlugin):
If `min_weight` is specified, tags are filtered by weight.
"""
+ # Work around an inconsistency in pylast where
+ # Album.get_top_tags() does not return TopItem instances.
+ # https://code.google.com/p/pylast/issues/detail?id=85
+ if isinstance(obj, pylast.Album):
+ obj = super(pylast.Album, obj)
+
try:
- # Work around an inconsistency in pylast where
- # Album.get_top_tags() does not return TopItem instances.
- # https://code.google.com/p/pylast/issues/detail?id=85
- if isinstance(obj, pylast.Album):
- res = super(pylast.Album, obj).get_top_tags()
- else:
- res = obj.get_top_tags()
+ res = obj.get_top_tags()
except PYLAST_EXCEPTIONS as exc:
self._log.debug(u'last.fm error: {0}', exc)
return []
+ except Exception as exc:
+ # Isolate bugs in pylast.
+ self._log.debug(traceback.format_exc())
+ self._log.error('error in pylast library: {0}', exc)
+ return []
# Filter by weight (optionally).
if min_weight:
diff --git a/beetsplug/lastimport.py b/beetsplug/lastimport.py
index 66da6880d..0303db219 100644
--- a/beetsplug/lastimport.py
+++ b/beetsplug/lastimport.py
@@ -51,8 +51,8 @@ class LastImportPlugin(plugins.BeetsPlugin):
def import_lastfm(lib, log):
- user = config['lastfm']['user']
- per_page = config['lastimport']['per_page']
+ user = config['lastfm']['user'].get(unicode)
+ per_page = config['lastimport']['per_page'].get(int)
if not user:
raise ui.UserError('You must specify a user name for lastimport')
diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py
index 365d83193..974f7e894 100644
--- a/beetsplug/mbsync.py
+++ b/beetsplug/mbsync.py
@@ -52,8 +52,7 @@ class MBSyncPlugin(BeetsPlugin):
cmd.parser.add_option('-W', '--nowrite', action='store_false',
default=config['import']['write'], dest='write',
help="don't write updated metadata to files")
- cmd.parser.add_option('-f', '--format', action='store', default='',
- help='print with custom format')
+ cmd.parser.add_format_option()
cmd.func = self.func
return [cmd]
@@ -64,17 +63,16 @@ class MBSyncPlugin(BeetsPlugin):
pretend = opts.pretend
write = opts.write
query = ui.decargs(args)
- fmt = opts.format
- self.singletons(lib, query, move, pretend, write, fmt)
- self.albums(lib, query, move, pretend, write, fmt)
+ self.singletons(lib, query, move, pretend, write)
+ self.albums(lib, query, move, pretend, write)
- def singletons(self, lib, query, move, pretend, write, fmt):
+ def singletons(self, lib, query, move, pretend, write):
"""Retrieve and apply info from the autotagger for items matched by
query.
"""
for item in lib.items(query + ['singleton:true']):
- item_formatted = format(item, fmt)
+ item_formatted = format(item)
if not item.mb_trackid:
self._log.info(u'Skipping singleton with no mb_trackid: {0}',
item_formatted)
@@ -93,13 +91,13 @@ class MBSyncPlugin(BeetsPlugin):
autotag.apply_item_metadata(item, track_info)
apply_item_changes(lib, item, move, pretend, write)
- def albums(self, lib, query, move, pretend, write, fmt):
+ def albums(self, lib, query, move, pretend, write):
"""Retrieve and apply info from the autotagger for albums matched by
query and their items.
"""
# Process matching albums.
for a in lib.albums(query):
- album_formatted = format(a, fmt)
+ album_formatted = format(a)
if not a.mb_albumid:
self._log.info(u'Skipping album with no mb_albumid: {0}',
album_formatted)
diff --git a/beetsplug/missing.py b/beetsplug/missing.py
index 517a20758..816449cc4 100644
--- a/beetsplug/missing.py
+++ b/beetsplug/missing.py
@@ -94,19 +94,13 @@ class MissingPlugin(BeetsPlugin):
self._command = Subcommand('missing',
help=__doc__,
aliases=['miss'])
-
- self._command.parser.add_option('-f', '--format', dest='format',
- action='store', type='string',
- help='print with custom FORMAT',
- metavar='FORMAT', default='')
-
self._command.parser.add_option('-c', '--count', dest='count',
action='store_true',
help='count missing tracks per album')
-
self._command.parser.add_option('-t', '--total', dest='total',
action='store_true',
help='count total of missing tracks')
+ self._command.parser.add_format_option()
def commands(self):
def _miss(lib, opts, args):
diff --git a/beetsplug/mpdstats.py b/beetsplug/mpdstats.py
index c021d7465..e40c3a7d4 100644
--- a/beetsplug/mpdstats.py
+++ b/beetsplug/mpdstats.py
@@ -166,8 +166,8 @@ class MPDStats(object):
else:
rolling = (rating + (1.0 - rating) / 2.0)
stable = (play_count + 1.0) / (play_count + skip_count + 2.0)
- return (self.rating_mix * stable
- + (1.0 - self.rating_mix) * rolling)
+ return (self.rating_mix * stable +
+ (1.0 - self.rating_mix) * rolling)
def get_item(self, path):
"""Return the beets item related to path.
diff --git a/beetsplug/mpdupdate.py b/beetsplug/mpdupdate.py
index 96141c567..dfe402497 100644
--- a/beetsplug/mpdupdate.py
+++ b/beetsplug/mpdupdate.py
@@ -80,7 +80,7 @@ class MPDUpdatePlugin(BeetsPlugin):
self.register_listener('database_change', self.db_change)
- def db_change(self, lib):
+ def db_change(self, lib, model):
self.register_listener('cli_exit', self.update)
def update(self, lib):
diff --git a/beetsplug/permissions.py b/beetsplug/permissions.py
index 256f09e52..a85bff6b5 100644
--- a/beetsplug/permissions.py
+++ b/beetsplug/permissions.py
@@ -6,10 +6,12 @@ like the following in your config.yaml to configure:
permissions:
file: 644
+ dir: 755
"""
import os
from beets import config, util
from beets.plugins import BeetsPlugin
+from beets.util import ancestry
def convert_perm(perm):
@@ -28,38 +30,52 @@ def check_permissions(path, permission):
return oct(os.stat(path).st_mode & 0o777) == oct(permission)
+def dirs_in_library(library, item):
+ """Creates a list of ancestor directories in the beets library path.
+ """
+ return [ancestor
+ for ancestor in ancestry(item)
+ if ancestor.startswith(library)][1:]
+
+
class Permissions(BeetsPlugin):
def __init__(self):
super(Permissions, self).__init__()
# Adding defaults.
self.config.add({
- u'file': 644
+ u'file': 644,
+ u'dir': 755
})
+ self.register_listener('item_imported', permissions)
+ self.register_listener('album_imported', permissions)
+
-@Permissions.listen('item_imported')
-@Permissions.listen('album_imported')
def permissions(lib, item=None, album=None):
"""Running the permission fixer.
"""
# Getting the config.
file_perm = config['permissions']['file'].get()
+ dir_perm = config['permissions']['dir'].get()
- # Converts file permissions to oct.
+ # Converts permissions to oct.
file_perm = convert_perm(file_perm)
+ dir_perm = convert_perm(dir_perm)
# Create chmod_queue.
- chmod_queue = []
+ file_chmod_queue = []
if item:
- chmod_queue.append(item.path)
+ file_chmod_queue.append(item.path)
elif album:
for album_item in album.items():
- chmod_queue.append(album_item.path)
+ file_chmod_queue.append(album_item.path)
- # Setting permissions for every path in the queue.
- for path in chmod_queue:
- # Changing permissions on the destination path.
+ # A set of directories to change permissions for.
+ dir_chmod_queue = set()
+
+ for path in file_chmod_queue:
+ # Changing permissions on the destination file.
os.chmod(util.bytestring_path(path), file_perm)
# Checks if the destination path has the permissions configured.
@@ -67,3 +83,19 @@ def permissions(lib, item=None, album=None):
message = 'There was a problem setting permission on {}'.format(
path)
print(message)
+
+ # Adding directories to the directory chmod queue.
+ dir_chmod_queue.update(
+ dirs_in_library(config['directory'].get(),
+ path))
+
+ # Change permissions for the directories.
+ for path in dir_chmod_queue:
+ # Chaning permissions on the destination directory.
+ os.chmod(util.bytestring_path(path), dir_perm)
+
+ # Checks if the destination path has the permissions configured.
+ if not check_permissions(util.bytestring_path(path), dir_perm):
+ message = 'There was a problem setting permission on {}'.format(
+ path)
+ print(message)
diff --git a/beetsplug/play.py b/beetsplug/play.py
index f6a59098f..65d5b91ec 100644
--- a/beetsplug/play.py
+++ b/beetsplug/play.py
@@ -17,104 +17,15 @@
from __future__ import (division, absolute_import, print_function,
unicode_literals)
-from functools import partial
-
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
from beets import config
from beets import ui
from beets import util
from os.path import relpath
-import platform
-import shlex
from tempfile import NamedTemporaryFile
-def play_music(lib, opts, args, log):
- """Execute query, create temporary playlist and execute player
- command passing that playlist.
- """
- command_str = config['play']['command'].get()
- use_folders = config['play']['use_folders'].get(bool)
- relative_to = config['play']['relative_to'].get()
- if relative_to:
- relative_to = util.normpath(relative_to)
- if command_str:
- command = shlex.split(command_str)
- else:
- # If a command isn't set, then let the OS decide how to open the
- # playlist.
- sys_name = platform.system()
- if sys_name == 'Darwin':
- command = ['open']
- elif sys_name == 'Windows':
- command = ['start']
- else:
- # If not Mac or Windows, then assume Unixy.
- command = ['xdg-open']
-
- # Preform search by album and add folders rather then tracks to playlist.
- if opts.album:
- selection = lib.albums(ui.decargs(args))
- paths = []
-
- for album in selection:
- if use_folders:
- paths.append(album.item_dir())
- else:
- # TODO use core's sorting functionality
- paths.extend([item.path for item in sorted(
- album.items(), key=lambda item: (item.disc, item.track))])
- item_type = 'album'
-
- # Preform item query and add tracks to playlist.
- else:
- selection = lib.items(ui.decargs(args))
- paths = [item.path for item in selection]
- item_type = 'track'
-
- item_type += 's' if len(selection) > 1 else ''
-
- if not selection:
- 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(
- 'text_warning',
- 'You are about to queue {0} {1}.'.format(len(selection), item_type)
- ))
-
- if ui.input_options(('Continue', 'Abort')) == 'a':
- return
-
- # Create temporary m3u file to hold our playlist.
- m3u = NamedTemporaryFile('w', suffix='.m3u', delete=False)
- for item in paths:
- if relative_to:
- m3u.write(relpath(item, relative_to) + '\n')
- else:
- m3u.write(item + '\n')
- m3u.close()
-
- command.append(m3u.name)
-
- # Invoke the command and log the output.
- output = util.command_output(command)
- if output:
- log.debug(u'Output of {0}: {1}',
- util.displayable_path(command[0]),
- output.decode('utf8', 'ignore'))
- else:
- log.debug(u'no output')
-
- ui.print_(u'Playing {0} {1}.'.format(len(selection), item_type))
-
- util.remove(m3u.name)
-
-
class PlayPlugin(BeetsPlugin):
def __init__(self):
@@ -131,10 +42,74 @@ class PlayPlugin(BeetsPlugin):
'play',
help='send music to a player as a playlist'
)
- play_command.parser.add_option(
- '-a', '--album',
- action='store_true', default=False,
- help='query and load albums rather than tracks'
- )
- play_command.func = partial(play_music, log=self._log)
+ play_command.parser.add_album_option()
+ play_command.func = self.play_music
return [play_command]
+
+ def play_music(self, lib, opts, args):
+ """Execute query, create temporary playlist and execute player
+ command passing that playlist.
+ """
+ command_str = config['play']['command'].get()
+ use_folders = config['play']['use_folders'].get(bool)
+ relative_to = config['play']['relative_to'].get()
+ if relative_to:
+ relative_to = util.normpath(relative_to)
+
+ # Perform search by album and add folders rather than tracks to
+ # playlist.
+ if opts.album:
+ selection = lib.albums(ui.decargs(args))
+ paths = []
+
+ sort = lib.get_default_album_sort()
+ for album in selection:
+ if use_folders:
+ paths.append(album.item_dir())
+ else:
+ paths.extend(item.path
+ for item in sort.sort(album.items()))
+ item_type = 'album'
+
+ # Perform item query and add tracks to playlist.
+ else:
+ selection = lib.items(ui.decargs(args))
+ paths = [item.path for item in selection]
+ item_type = 'track'
+
+ item_type += 's' if len(selection) > 1 else ''
+
+ if not selection:
+ 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(
+ 'text_warning',
+ 'You are about to queue {0} {1}.'.format(len(selection),
+ item_type)
+ ))
+
+ if ui.input_options(('Continue', 'Abort')) == 'a':
+ return
+
+ # Create temporary m3u file to hold our playlist.
+ m3u = NamedTemporaryFile('w', suffix='.m3u', delete=False)
+ for item in paths:
+ if relative_to:
+ m3u.write(relpath(item, relative_to) + b'\n')
+ else:
+ m3u.write(item + b'\n')
+ m3u.close()
+
+ ui.print_(u'Playing {0} {1}.'.format(len(selection), item_type))
+
+ try:
+ util.interactive_open(m3u.name, command_str)
+ except OSError as exc:
+ raise ui.UserError("Could not play the music playlist: "
+ "{0}".format(exc))
+ finally:
+ util.remove(m3u.name)
diff --git a/beetsplug/plexupdate.py b/beetsplug/plexupdate.py
index 9781e300e..5aa096486 100644
--- a/beetsplug/plexupdate.py
+++ b/beetsplug/plexupdate.py
@@ -55,7 +55,7 @@ class PlexUpdate(BeetsPlugin):
self.register_listener('database_change', self.listen_for_db_change)
- def listen_for_db_change(self, lib):
+ def listen_for_db_change(self, lib, model):
"""Listens for beets db change and register the update for the end"""
self.register_listener('cli_exit', self.update)
diff --git a/beetsplug/random.py b/beetsplug/random.py
index f6a664a4c..43fb7957c 100644
--- a/beetsplug/random.py
+++ b/beetsplug/random.py
@@ -26,7 +26,6 @@ from itertools import groupby
def random_item(lib, opts, args):
query = decargs(args)
- fmt = '$path' if opts.path else opts.format
if opts.album:
objs = list(lib.albums(query))
@@ -63,20 +62,15 @@ def random_item(lib, opts, args):
objs = random.sample(objs, number)
for item in objs:
- print_(format(item, fmt))
+ print_(format(item))
random_cmd = Subcommand('random',
help='chose a random track or album')
-random_cmd.parser.add_option('-a', '--album', action='store_true',
- help='choose an album instead of track')
-random_cmd.parser.add_option('-p', '--path', action='store_true',
- help='print the path of the matched item')
-random_cmd.parser.add_option('-f', '--format', action='store',
- help='print with custom format', default='')
random_cmd.parser.add_option('-n', '--number', action='store', type="int",
help='number of objects to choose', default=1)
random_cmd.parser.add_option('-e', '--equal-chance', action='store_true',
help='each artist has the same chance')
+random_cmd.parser.add_all_common_options()
random_cmd.func = random_item
diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py
index 4ec407cfb..5159816d4 100644
--- a/beetsplug/replaygain.py
+++ b/beetsplug/replaygain.py
@@ -21,6 +21,7 @@ import collections
import itertools
import sys
import warnings
+import re
from beets import logging
from beets import ui
@@ -68,6 +69,7 @@ AlbumGain = collections.namedtuple("AlbumGain", "album_gain track_gains")
class Backend(object):
"""An abstract class representing engine for calculating RG values.
"""
+
def __init__(self, config, log):
"""Initialize the backend with the configuration view for the
plugin.
@@ -83,10 +85,112 @@ class Backend(object):
raise NotImplementedError()
+# bsg1770gain backend
+class Bs1770gainBackend(Backend):
+ """bs1770gain is a loudness scanner compliant with ITU-R BS.1770 and
+ its flavors EBU R128, ATSC A/85 and Replaygain 2.0.
+ """
+
+ def __init__(self, config, log):
+ super(Bs1770gainBackend, self).__init__(config, log)
+ cmd = b'bs1770gain'
+
+ try:
+ self.method = b'--' + config['method'].get(str)
+ except:
+ self.method = b'--replaygain'
+
+ try:
+ call([cmd, self.method])
+ self.command = cmd
+ except OSError:
+ raise FatalReplayGainError(
+ 'Is bs1770gain installed? Is your method in config correct?'
+ )
+ if not self.command:
+ raise FatalReplayGainError(
+ 'no replaygain command found: install bs1770gain'
+ )
+
+ def compute_track_gain(self, items):
+ """Computes the track gain of the given tracks, returns a list
+ of TrackGain objects.
+ """
+
+ output = self.compute_gain(items, False)
+ return output
+
+ def compute_album_gain(self, album):
+ """Computes the album gain of the given album, returns an
+ AlbumGain object.
+ """
+ # TODO: What should be done when not all tracks in the album are
+ # supported?
+
+ supported_items = album.items()
+ output = self.compute_gain(supported_items, True)
+
+ return AlbumGain(output[-1], output[:-1])
+
+ def compute_gain(self, items, is_album):
+ """Computes the track or album gain of a list of items, returns
+ a list of TrackGain objects.
+ When computing album gain, the last TrackGain object returned is
+ the album gain
+ """
+
+ if len(items) == 0:
+ return []
+
+ """Compute ReplayGain values and return a list of results
+ dictionaries as given by `parse_tool_output`.
+ """
+ # Construct shell command.
+ cmd = [self.command]
+ cmd = cmd + [self.method]
+ cmd = cmd + [b'-it']
+ cmd = cmd + [syspath(i.path) for i in items]
+
+ self._log.debug(u'analyzing {0} files', len(items))
+ self._log.debug(u"executing {0}", " ".join(map(displayable_path, cmd)))
+ output = call(cmd)
+ self._log.debug(u'analysis finished')
+ results = self.parse_tool_output(output,
+ len(items) + is_album)
+ return results
+
+ def parse_tool_output(self, text, num_lines):
+ """Given the output from bs1770gain, parse the text and
+ return a list of dictionaries
+ containing information about each analyzed file.
+ """
+ out = []
+ data = text.decode('utf8', errors='ignore')
+ regex = ("(\s{2,2}\[\d+\/\d+\].*?|\[ALBUM\].*?)(?=\s{2,2}\[\d+\/\d+\]"
+ "|\s{2,2}\[ALBUM\]:|done\.$)")
+
+ results = re.findall(regex, data, re.S | re.M)
+ for ll in results[0:num_lines]:
+ parts = ll.split(b'\n')
+ if len(parts) == 0:
+ self._log.debug(u'bad tool output: {0!r}', text)
+ raise ReplayGainError('bs1770gain failed')
+
+ d = {
+ 'file': parts[0],
+ 'gain': float((parts[1].split('/'))[1].split('LU')[0]),
+ 'peak': float(parts[2].split('/')[1]),
+ }
+
+ self._log.info('analysed {}gain={};peak={}',
+ d['file'].rstrip(), d['gain'], d['peak'])
+ out.append(Gain(d['gain'], d['peak']))
+ return out
+
+
# mpgain/aacgain CLI tool backend.
-
-
class CommandBackend(Backend):
+
def __init__(self, config, log):
super(CommandBackend, self).__init__(config, log)
config.add({
@@ -106,9 +210,9 @@ class CommandBackend(Backend):
)
else:
# Check whether the program is in $PATH.
- for cmd in ('mp3gain', 'aacgain'):
+ for cmd in (b'mp3gain', b'aacgain'):
try:
- call([cmd, '-v'])
+ call([cmd, b'-v'])
self.command = cmd
except OSError:
pass
@@ -172,15 +276,14 @@ class CommandBackend(Backend):
# tag-writing; this turns the mp3gain/aacgain tool into a gain
# calculator rather than a tag manipulator because we take care
# of changing tags ourselves.
- cmd = [self.command, '-o', '-s', 's']
+ cmd = [self.command, b'-o', b'-s', b's']
if self.noclip:
# Adjust to avoid clipping.
- cmd = cmd + ['-k']
+ cmd = cmd + [b'-k']
else:
# Disable clipping warning.
- cmd = cmd + ['-c']
- cmd = cmd + ['-a' if is_album else '-r']
- cmd = cmd + ['-d', bytes(self.gain_offset)]
+ cmd = cmd + [b'-c']
+ cmd = cmd + [b'-d', bytes(self.gain_offset)]
cmd = cmd + [syspath(i.path) for i in items]
self._log.debug(u'analyzing {0} files', len(items))
@@ -219,6 +322,7 @@ class CommandBackend(Backend):
# GStreamer-based backend.
class GStreamerBackend(Backend):
+
def __init__(self, config, log):
super(GStreamerBackend, self).__init__(config, log)
self._import_gst()
@@ -471,8 +575,9 @@ class AudioToolsBackend(Backend):
`_ and its capabilities to read more
file formats and compute ReplayGain values using it replaygain module.
"""
+
def __init__(self, config, log):
- super(CommandBackend, self).__init__(config, log)
+ super(AudioToolsBackend, self).__init__(config, log)
self._import_audiotools()
def _import_audiotools(self):
@@ -599,9 +704,10 @@ class ReplayGainPlugin(BeetsPlugin):
"""
backends = {
- "command": CommandBackend,
+ "command": CommandBackend,
"gstreamer": GStreamerBackend,
- "audiotools": AudioToolsBackend
+ "audiotools": AudioToolsBackend,
+ "bs1770gain": Bs1770gainBackend
}
def __init__(self):
@@ -761,7 +867,6 @@ class ReplayGainPlugin(BeetsPlugin):
self.handle_track(item, write)
cmd = ui.Subcommand('replaygain', help='analyze for ReplayGain')
- cmd.parser.add_option('-a', '--album', action='store_true',
- help='analyze albums instead of tracks')
+ cmd.parser.add_album_option()
cmd.func = func
return [cmd]
diff --git a/beetsplug/scrub.py b/beetsplug/scrub.py
index f6a3bed27..ac0017474 100644
--- a/beetsplug/scrub.py
+++ b/beetsplug/scrub.py
@@ -139,7 +139,7 @@ class ScrubPlugin(BeetsPlugin):
self._log.error(u'could not scrub {0}: {1}',
util.displayable_path(path), exc)
- def write_item(self, path):
+ def write_item(self, item, path, tags):
"""Automatically embed art into imported albums."""
if not scrubbing and self.config['auto']:
self._log.debug(u'auto-scrubbing {0}', util.displayable_path(path))
diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py
index e2c256b2b..8889a2534 100644
--- a/beetsplug/smartplaylist.py
+++ b/beetsplug/smartplaylist.py
@@ -21,30 +21,14 @@ from __future__ import (division, absolute_import, print_function,
from beets.plugins import BeetsPlugin
from beets import ui
from beets.util import mkdirall, normpath, syspath
+from beets.library import Item, Album, parse_query_string
+from beets.dbcore import OrQuery
+from beets.dbcore.query import MultipleSort
import os
-def _items_for_query(lib, queries, album):
- """Get the matching items for a list of queries.
-
- `queries` can either be a single string or a list of strings. In the
- latter case, the results from each query are concatenated. `album`
- indicates whether the queries are item-level or album-level.
- """
- if isinstance(queries, basestring):
- queries = [queries]
- if album:
- for query in queries:
- for album in lib.albums(query):
- for item in album.items():
- yield item
- else:
- for query in queries:
- for item in lib.items(query):
- yield item
-
-
class SmartPlaylistPlugin(BeetsPlugin):
+
def __init__(self):
super(SmartPlaylistPlugin, self).__init__()
self.config.add({
@@ -54,42 +38,139 @@ class SmartPlaylistPlugin(BeetsPlugin):
'playlists': []
})
+ self._matched_playlists = None
+ self._unmatched_playlists = None
+
if self.config['auto']:
self.register_listener('database_change', self.db_change)
def commands(self):
- def update(lib, opts, args):
- self.update_playlists(lib)
spl_update = ui.Subcommand('splupdate',
- help='update the smart playlists')
- spl_update.func = update
+ help='update the smart playlists. Playlist '
+ 'names may be passed as arguments.')
+ spl_update.func = self.update_cmd
return [spl_update]
- def db_change(self, lib):
- self.register_listener('cli_exit', self.update_playlists)
+ def update_cmd(self, lib, opts, args):
+ self.build_queries()
+ if args:
+ args = set(ui.decargs(args))
+ for a in list(args):
+ if not a.endswith(".m3u"):
+ args.add("{0}.m3u".format(a))
+
+ playlists = set((name, q, a_q)
+ for name, q, a_q in self._unmatched_playlists
+ if name in args)
+ if not playlists:
+ raise ui.UserError('No playlist matching any of {0} '
+ 'found'.format([name for name, _, _ in
+ self._unmatched_playlists]))
+
+ self._matched_playlists = playlists
+ self._unmatched_playlists -= playlists
+ else:
+ self._matched_playlists = self._unmatched_playlists
+
+ self.update_playlists(lib)
+
+ def build_queries(self):
+ """
+ Instanciate queries for the playlists.
+
+ Each playlist has 2 queries: one or items one for albums, each with a
+ sort. We must also remember its name. _unmatched_playlists is a set of
+ tuples (name, (q, q_sort), (album_q, album_q_sort)).
+
+ sort may be any sort, or NullSort, or None. None and NullSort are
+ equivalent and both eval to False.
+ More precisely
+ - it will be NullSort when a playlist query ('query' or 'album_query')
+ is a single item or a list with 1 element
+ - it will be None when there are multiple items i a query
+ """
+ self._unmatched_playlists = set()
+ self._matched_playlists = set()
+
+ for playlist in self.config['playlists'].get(list):
+ playlist_data = (playlist['name'],)
+ for key, Model in (('query', Item), ('album_query', Album)):
+ qs = playlist.get(key)
+ if qs is None:
+ query_and_sort = None, None
+ elif isinstance(qs, basestring):
+ query_and_sort = parse_query_string(qs, Model)
+ elif len(qs) == 1:
+ query_and_sort = parse_query_string(qs[0], Model)
+ else:
+ # multiple queries and sorts
+ queries, sorts = zip(*(parse_query_string(q, Model)
+ for q in qs))
+ query = OrQuery(queries)
+ final_sorts = []
+ for s in sorts:
+ if s:
+ if isinstance(s, MultipleSort):
+ final_sorts += s.sorts
+ else:
+ final_sorts.append(s)
+ if not final_sorts:
+ sort = None
+ elif len(final_sorts) == 1:
+ sort, = final_sorts
+ else:
+ sort = MultipleSort(final_sorts)
+ query_and_sort = query, sort
+
+ playlist_data += (query_and_sort,)
+
+ self._unmatched_playlists.add(playlist_data)
+
+ def matches(self, model, query, album_query):
+ if album_query and isinstance(model, Album):
+ return album_query.match(model)
+ if query and isinstance(model, Item):
+ return query.match(model)
+ return False
+
+ def db_change(self, lib, model):
+ if self._unmatched_playlists is None:
+ self.build_queries()
+
+ for playlist in self._unmatched_playlists:
+ n, (q, _), (a_q, _) = playlist
+ if self.matches(model, q, a_q):
+ self._log.debug("{0} will be updated because of {1}", n, model)
+ self._matched_playlists.add(playlist)
+ self.register_listener('cli_exit', self.update_playlists)
+
+ self._unmatched_playlists -= self._matched_playlists
def update_playlists(self, lib):
- self._log.info("Updating smart playlists...")
- playlists = self.config['playlists'].get(list)
+ self._log.info("Updating {0} smart playlists...",
+ len(self._matched_playlists))
+
playlist_dir = self.config['playlist_dir'].as_filename()
relative_to = self.config['relative_to'].get()
if relative_to:
relative_to = normpath(relative_to)
- for playlist in playlists:
- self._log.debug(u"Creating playlist {0[name]}", playlist)
+ for playlist in self._matched_playlists:
+ name, (query, q_sort), (album_query, a_q_sort) = playlist
+ self._log.debug(u"Creating playlist {0}", name)
items = []
- if 'album_query' in playlist:
- items.extend(_items_for_query(lib, playlist['album_query'],
- True))
- if 'query' in playlist:
- items.extend(_items_for_query(lib, playlist['query'], False))
+
+ if query:
+ items.extend(lib.items(query, q_sort))
+ if album_query:
+ for album in lib.albums(album_query, a_q_sort):
+ items.extend(album.items())
m3us = {}
# As we allow tags in the m3u names, we'll need to iterate through
# the items and generate the correct m3u file names.
for item in items:
- m3u_name = item.evaluate_template(playlist['name'], True)
+ m3u_name = item.evaluate_template(name, True)
if m3u_name not in m3us:
m3us[m3u_name] = []
item_path = item.path
@@ -104,4 +185,4 @@ class SmartPlaylistPlugin(BeetsPlugin):
with open(syspath(m3u_path), 'w') as f:
for path in m3us[m3u]:
f.write(path + b'\n')
- self._log.info("{0} playlists updated", len(playlists))
+ self._log.info("{0} playlists updated", len(self._matched_playlists))
diff --git a/beetsplug/the.py b/beetsplug/the.py
index 3fb16dcf9..538b81245 100644
--- a/beetsplug/the.py
+++ b/beetsplug/the.py
@@ -30,12 +30,6 @@ FORMAT = u'{0}, {1}'
class ThePlugin(BeetsPlugin):
- _instance = None
-
- the = True
- a = True
- format = u''
- strip = False
patterns = []
def __init__(self):
diff --git a/docs/changelog.rst b/docs/changelog.rst
index aa1ee2907..33e66ef10 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -6,6 +6,23 @@ Changelog
Features:
+* :doc:`/plugins/smartplaylist`: detect for each playlist if it needs to be
+ regenated, instead of systematically regenerating all of them after a
+ database modification.
+* :doc:`/plugins/smartplaylist`: the ``splupdate`` command can now take
+ additinal parameters: names of the playlists to regenerate.
+* Beets now accept top-level options ``--format-item`` and ``--format-album``
+ before any subcommand to control how items and albums are displayed.
+ :bug:`1271`
+* :doc:`/plugins/replaygain`: There is a new backend for the `bs1770gain`_
+ tool. Thanks to :user:`jmwatte`. :bug:`1343`
+* There are now multiple levels of verbosity. On the command line, you can
+ make beets somewhat verbose with ``-v`` or very verbose with ``-vv``.
+ :bug:`1244`
+* :doc:`/plugins/play` will sort items according to the configured option when
+ used in album mode.
+* :doc:`/plugins/play` gives full interaction with the command invoked.
+ :bug:`1321`
* 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``,
@@ -46,6 +63,10 @@ Features:
* 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`
+* The data source used during import (e.g., MusicBrainz) is now saved as a
+ flexible attribute `data_source` of an Item/Album. :bug:`1311`
+* :doc:`/plugins/permissions`: Now handles also the permissions of the
+ directories. :bug:`1308` :bug:`1324`
Core changes:
@@ -60,13 +81,19 @@ Core changes:
``albumtotal`` computed attribute that provides the total number of tracks
on the album. (The :ref:`per_disc_numbering` option has no influence on this
field.)
-* The :ref:`list_format_album` and :ref:`list_format_item` configuration keys
+* The `list_format_album` and `list_format_item` configuration keys
now affect (almost) every place where objects are printed and logged.
(Previously, they only controlled the :ref:`list-cmd` command and a few
other scattered pieces.) :bug:`1269`
+* `list_format_album` and `list_format_album` have respectively been
+ renamed :ref:`format_album` and :ref:`format_item`. The old names still work
+ but each triggers a warning message. :bug:`1271`
Fixes:
+* :doc:`/plugins/replaygain`: Stop applying replaygain directly to source files
+ when using the mp3gain backend. :bug:`1316`
+* Path queries are case-sensitive on non-Windows OSes. :bug:`1165`
* :doc:`/plugins/lyrics`: Silence a warning about insecure requests in the new
MusixMatch backend. :bug:`1204`
* Fix a crash when ``beet`` is invoked without arguments. :bug:`1205`
@@ -105,18 +132,29 @@ Fixes:
Unicode filenames. :bug:`1297`
* :doc:`/plugins/discogs`: Handle and log more kinds of communication
errors. :bug:`1299` :bug:`1305`
+* :doc:`/plugins/lastgenre`: Bugs in the `pylast` library can no longer crash
+ beets.
For developers:
+* The ``database_change`` event now sends the item or album that is subject to
+ a change in the db.
+* the ``OptionParser`` is now a ``CommonOptionsParser`` that offers facilities
+ for adding usual options (``--album``, ``--path`` and ``--format``). See
+ :ref:`add_subcommands`. :bug:`1271`
* The logging system in beets has been overhauled. Plugins now each have their
own logger, which helps by automatically adjusting the verbosity level in
- import mode and by prefixing the plugin's name. Also, logging calls can (and
+ import mode and by prefixing the plugin's name. Logging levels are
+ dynamically set when a plugin is called, depending on how it is called
+ (import stage, event or direct command). Finally, logging calls can (and
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. It's also possible to replace the
originally created tasks by returning new ones using this event.
+.. _bs1770gain: http://bs1770gain.sourceforge.net
+
1.3.10 (January 5, 2015)
------------------------
diff --git a/docs/conf.py b/docs/conf.py
index 82fc15da8..4aeb66d33 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -23,7 +23,7 @@ extlinks = {
}
# Options for HTML output
-html_theme = 'default'
+html_theme = 'classic'
htmlhelp_basename = 'beetsdoc'
# Options for LaTeX output
diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst
index 97da193f0..1d610f53b 100644
--- a/docs/dev/plugins.rst
+++ b/docs/dev/plugins.rst
@@ -87,8 +87,11 @@ The function should use any of the utility functions defined in ``beets.ui``.
Try running ``pydoc beets.ui`` to see what's available.
You can add command-line options to your new command using the ``parser`` member
-of the ``Subcommand`` class, which is an ``OptionParser`` instance. Just use it
-like you would a normal ``OptionParser`` in an independent script.
+of the ``Subcommand`` class, which is a ``CommonOptionParser`` instance. Just
+use it like you would a normal ``OptionParser`` in an independent script. Note
+that it offers several methods to add common options: ``--album``, ``--path``
+and ``--format``. This feature is versatile and extensively documented, try
+``pydoc beets.ui.CommonOptionParser`` for more information.
.. _plugin_events:
@@ -200,7 +203,7 @@ The events currently available are:
Library object. Parameter: ``lib``.
* *database_change*: a modification has been made to the library database. The
- change might not be committed yet. Parameter: ``lib``.
+ change might not be committed yet. Parameters: ``lib`` and ``model``.
* *cli_exit*: called just before the ``beet`` command-line program exits.
Parameter: ``lib``.
@@ -382,7 +385,7 @@ Multiple stages run in parallel but each stage processes only one task at a time
and each task is processed by only one stage at a time.
Plugins provide stages as functions that take two arguments: ``config`` and
-``task``, which are ``ImportConfig`` and ``ImportTask`` objects (both defined in
+``task``, which are ``ImportSession`` and ``ImportTask`` objects (both defined in
``beets.importer``). Add such a function to the plugin's ``import_stages`` field
to register it::
@@ -391,7 +394,7 @@ to register it::
def __init__(self):
super(ExamplePlugin, self).__init__()
self.import_stages = [self.stage]
- def stage(self, config, task):
+ def stage(self, session, task):
print('Importing something!')
.. _extend-query:
@@ -480,14 +483,21 @@ str.format-style string formatting. So you can write logging calls like this::
.. _PEP 3101: https://www.python.org/dev/peps/pep-3101/
.. _standard Python logging module: https://docs.python.org/2/library/logging.html
-The per-plugin loggers have two convenient features:
+When beets is in verbose mode, plugin messages are prefixed with the plugin
+name to make them easier to see.
+
+What messages will be logged depends on the logging level and the action
+performed:
+
+* On import stages and event, the default is ``WARNING`` messages.
+* On direct actions, the default is ``INFO`` and ``WARNING`` message.
+
+The verbosity can be increased with ``--verbose`` flags: each flags lowers the
+level by a notch.
+
+This addresses a common pattern where plugins need to use the same code for a
+command and an import stage, but the command needs to print more messages than
+the import stage. (For example, you'll want to log "found lyrics for this song"
+when you're run explicitly as a command, but you don't want to noisily
+interrupt the importer interface when running automatically.)
-* When beets is in verbose mode, messages are prefixed with the plugin name to
- make them easier to see.
-* Messages at the ``INFO`` logging level are hidden when the plugin is running
- in an importer stage (see above). This addresses a common pattern where
- plugins need to use the same code for a command and an import stage, but the
- command needs to print more messages than the import stage. (For example,
- you'll want to log "found lyrics for this song" when you're run explicitly
- as a command, but you don't want to noisily interrupt the importer interface
- when running automatically.)
diff --git a/docs/plugins/duplicates.rst b/docs/plugins/duplicates.rst
index 18c2a8052..ecbda69cf 100644
--- a/docs/plugins/duplicates.rst
+++ b/docs/plugins/duplicates.rst
@@ -61,7 +61,7 @@ file. The available options mirror the command-line options:
or album. This uses the same template syntax as beets'
:doc:`path formats`. The usage is inspired by, and
therefore similar to, the :ref:`list ` command.
- Default: :ref:`list_format_item`
+ Default: :ref:`format_item`
- **full**: List every track or album that has duplicates, not just the
duplicates themselves.
Default: ``no``.
diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst
index c924e6e85..e301d3a7c 100644
--- a/docs/plugins/index.rst
+++ b/docs/plugins/index.rst
@@ -203,6 +203,8 @@ Here are a few of the plugins written by the beets community:
* `beets-follow`_ lets you check for new albums from artists you like.
+* `beets-setlister`_ generate playlists from the setlists of a given artist
+
.. _beets-check: https://github.com/geigerzaehler/beets-check
.. _copyartifacts: https://github.com/sbarakat/beets-copyartifacts
.. _dsedivec: https://github.com/dsedivec/beets-plugins
@@ -216,3 +218,4 @@ Here are a few of the plugins written by the beets community:
.. _beet-amazon: https://github.com/jmwatte/beet-amazon
.. _beets-alternatives: https://github.com/geigerzaehler/beets-alternatives
.. _beets-follow: https://github.com/nolsto/beets-follow
+.. _beets-setlister: https://github.com/tomjaspers/beets-setlister
diff --git a/docs/plugins/mbsync.rst b/docs/plugins/mbsync.rst
index cf89974e4..a7633a500 100644
--- a/docs/plugins/mbsync.rst
+++ b/docs/plugins/mbsync.rst
@@ -34,5 +34,5 @@ The command has a few command-line options:
plugin will write new metadata to files' tags. To disable this, use the
``-W`` (``--nowrite``) option.
* To customize the output of unrecognized items, use the ``-f``
- (``--format``) option. The default output is ``list_format_item`` or
- ``list_format_album`` for items and albums, respectively.
+ (``--format``) option. The default output is ``format_item`` or
+ ``format_album`` for items and albums, respectively.
diff --git a/docs/plugins/missing.rst b/docs/plugins/missing.rst
index ffca94052..aab04e71b 100644
--- a/docs/plugins/missing.rst
+++ b/docs/plugins/missing.rst
@@ -36,7 +36,7 @@ configuration file. The available options are:
track. This uses the same template syntax as beets'
:doc:`path formats `. The usage is inspired by, and
therefore similar to, the :ref:`list ` command.
- Default: :ref:`list_format_item`.
+ Default: :ref:`format_item`.
- **total**: Print a single count of missing tracks in all albums.
Default: ``no``.
diff --git a/docs/plugins/permissions.rst b/docs/plugins/permissions.rst
index 06b034b51..9c4cdc0aa 100644
--- a/docs/plugins/permissions.rst
+++ b/docs/plugins/permissions.rst
@@ -2,7 +2,7 @@ Permissions Plugin
==================
The ``permissions`` plugin allows you to set file permissions for imported
-music files.
+music files and its directories.
To use the ``permissions`` plugin, enable it in your configuration (see
:ref:`using-plugins`). Permissions will be adjusted automatically on import.
@@ -12,9 +12,10 @@ Configuration
To configure the plugin, make an ``permissions:`` section in your configuration
file. The ``file`` config value therein uses **octal modes** to specify the
-desired permissions. The default flags are octal 644.
+desired permissions. The default flags for files are octal 644 and 755 for directories.
Here's an example::
permissions:
file: 644
+ dir: 755
diff --git a/docs/plugins/play.rst b/docs/plugins/play.rst
index 325656aaa..939e3bec4 100644
--- a/docs/plugins/play.rst
+++ b/docs/plugins/play.rst
@@ -26,8 +26,8 @@ would on the command-line)::
play:
command: /usr/bin/command --option1 --option2 some_other_option
-Enable beets' verbose logging to see the command's output if you need to
-debug.
+While playing you'll be able to interact with the player if it is a
+command-line oriented, and you'll get its output in real time.
Configuration
-------------
diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst
index d572902dd..3411d3d40 100644
--- a/docs/plugins/replaygain.rst
+++ b/docs/plugins/replaygain.rst
@@ -10,9 +10,9 @@ playback levels.
Installation
------------
-This plugin can use one of three backends to compute the ReplayGain values:
-GStreamer, mp3gain (and its cousin, aacgain), and Python Audio Tools. mp3gain
-can be easier to install but GStreamer and Audio Tools support more audio
+This plugin can use one of four backends to compute the ReplayGain values:
+GStreamer, mp3gain (and its cousin, aacgain), Python Audio Tools and bs1770gain. mp3gain
+can be easier to install but GStreamer, Audio Tools and bs1770gain support more audio
formats.
Once installed, this plugin analyzes all files during the import process. This
@@ -75,6 +75,23 @@ On OS X, most of the dependencies can be installed with `Homebrew`_::
.. _Python Audio Tools: http://audiotools.sourceforge.net
+bs1770gain
+``````````
+
+In order to use this backend, you will need to install the bs1770gain command-line tool. Here are some hints:
+
+* goto `bs1770gain`_ and follow the download instructions
+* make sure it is in your $PATH
+
+.. _bs1770gain: http://bs1770gain.sourceforge.net/
+
+Then, enable the plugin (see :ref:`using-plugins`) and specify the
+backend in your configuration file::
+
+ replaygain:
+ backend: bs1770gain
+
+
Configuration
-------------
@@ -92,11 +109,6 @@ configuration file. The available options are:
These options only work with the "command" backend:
-- **apply**: If you use a player that does not support ReplayGain
- specifications, you can force the volume normalization by applying the gain
- to the file via the ``apply`` option. This is a lossless and reversible
- operation with no transcoding involved.
- Default: ``no``.
- **command**: The path to the ``mp3gain`` or ``aacgain`` executable (if beets
cannot find it by itself).
For example: ``/Applications/MacMP3Gain.app/Contents/Resources/aacgain``.
@@ -105,6 +117,14 @@ These options only work with the "command" backend:
would keep clipping from occurring.
Default: ``yes``.
+This option only works with the "bs1770gain" backend:
+
+- **method**: The loudness scanning standard: either `replaygain` for
+ ReplayGain 2.0, `ebu` for EBU R128, or `atsc` for ATSC A/85. This dictates
+ the reference level: -18, -23, or -24 LUFS respectively. Default:
+ `replaygain`
+
+
Manual Analysis
---------------
diff --git a/docs/plugins/smartplaylist.rst b/docs/plugins/smartplaylist.rst
index bc39e581e..2f691c4fe 100644
--- a/docs/plugins/smartplaylist.rst
+++ b/docs/plugins/smartplaylist.rst
@@ -44,6 +44,18 @@ You can also gather the results of several queries by putting them in a list.
- name: 'BeatlesUniverse.m3u'
query: ['artist:beatles', 'genre:"beatles cover"']
+Note that since beets query syntax is in effect, you can also use sorting
+directives::
+
+ - name: 'Chronological Beatles'
+ query: 'artist:Beatles year+'
+ - name: 'Mixed Rock'
+ query: ['artist:Beatles year+', 'artist:"Led Zeppelin" bitrate+']
+
+The former case behaves as expected, however please note that in the latter the
+sorts will be merged: ``year+ bitrate+`` will apply to both the Beatles and Led
+Zeppelin. If that bothers you, please get in touch.
+
For querying albums instead of items (mainly useful with extensible fields),
use the ``album_query`` field. ``query`` and ``album_query`` can be used at the
same time. The following example gathers single items but also items belonging
@@ -53,13 +65,16 @@ to albums that have a ``for_travel`` extensible field set to 1::
album_query: 'for_travel:1'
query: 'for_travel:1'
-By default, all playlists are automatically regenerated at the end of the
-session if the library database was changed. To force regeneration, you can
-invoke it manually from the command line::
+By default, each playlist is automatically regenerated at the end of the
+session if an item or album it matches changed in the library database. To
+force regeneration, you can invoke it manually from the command line::
$ beet splupdate
-which will generate your new smart playlists.
+This will regenerate all smart playlists. You can also specify which ones you
+want to regenerate::
+
+ $ beet splupdate BeatlesUniverse.m3u MyTravelPlaylist
You can also use this plugin together with the :doc:`mpdupdate`, in order to
automatically notify MPD of the playlist change, by adding ``mpdupdate`` to
diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst
index e569c073c..321b766d4 100644
--- a/docs/reference/cli.rst
+++ b/docs/reference/cli.rst
@@ -369,7 +369,8 @@ import ...``.
* ``-l LIBPATH``: specify the library database file to use.
* ``-d DIRECTORY``: specify the library root directory.
* ``-v``: verbose mode; prints out a deluge of debugging information. Please use
- this flag when reporting bugs.
+ this flag when reporting bugs. You can use it twice, as in ``-vv``, to make
+ beets even more verbose.
* ``-c FILE``: read a specified YAML :doc:`configuration file `.
Beets also uses the ``BEETSDIR`` environment variable to look for
diff --git a/docs/reference/config.rst b/docs/reference/config.rst
index a64e73749..fc686a8b8 100644
--- a/docs/reference/config.rst
+++ b/docs/reference/config.rst
@@ -162,25 +162,32 @@ Either ``yes`` or ``no``, indicating whether the autotagger should use
multiple threads. This makes things faster but may behave strangely.
Defaults to ``yes``.
-.. _list_format_item:
-list_format_item
-~~~~~~~~~~~~~~~~
+.. _list_format_item:
+.. _format_item:
+
+format_item
+~~~~~~~~~~~
Format to use when listing *individual items* with the :ref:`list-cmd`
command and other commands that need to print out items. Defaults to
``$artist - $album - $title``. The ``-f`` command-line option overrides
this setting.
-.. _list_format_album:
+It used to be named `list_format_item`.
-list_format_album
-~~~~~~~~~~~~~~~~~
+.. _list_format_album:
+.. _format_album:
+
+format_album
+~~~~~~~~~~~~
Format to use when listing *albums* with :ref:`list-cmd` and other
commands. Defaults to ``$albumartist - $album``. The ``-f`` command-line
option overrides this setting.
+It used to be named `list_format_album`.
+
.. _sort_item:
sort_item
diff --git a/docs/reference/query.rst b/docs/reference/query.rst
index 7dc79461a..20c5360f8 100644
--- a/docs/reference/query.rst
+++ b/docs/reference/query.rst
@@ -184,6 +184,8 @@ Note that this only matches items that are *already in your library*, so a path
query won't necessarily find *all* the audio files in a directory---just the
ones you've already added to your beets library.
+Path queries are case-sensitive on most platforms but case-insensitive on
+Windows.
.. _query-sort:
diff --git a/setup.py b/setup.py
index 3e651c623..78937b39e 100755
--- a/setup.py
+++ b/setup.py
@@ -82,9 +82,8 @@ setup(
'unidecode',
'musicbrainzngs>=0.4',
'pyyaml',
- ]
- + (['colorama'] if (sys.platform == 'win32') else [])
- + (['ordereddict'] if sys.version_info < (2, 7, 0) else []),
+ ] + (['colorama'] if (sys.platform == 'win32') else []) +
+ (['ordereddict'] if sys.version_info < (2, 7, 0) else []),
tests_require=[
'beautifulsoup4',
diff --git a/test/helper.py b/test/helper.py
index e929eecff..3de31e2df 100644
--- a/test/helper.py
+++ b/test/helper.py
@@ -15,7 +15,7 @@
"""This module includes various helpers that provide fixtures, capture
information or mock the environment.
-- The `control_stdin` and `capture_output` context managers allow one to
+- The `control_stdin` and `capture_stdout` context managers allow one to
interact with the user interface.
- `has_program` checks the presence of a command on the system.
@@ -51,6 +51,7 @@ from beets.library import Library, Item, Album
from beets import importer
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.mediafile import MediaFile, Image
+from beets.ui import _encoding
# TODO Move AutotagMock here
from test import _common
@@ -117,9 +118,13 @@ def capture_stdout():
def has_program(cmd, args=['--version']):
"""Returns `True` if `cmd` can be executed.
"""
+ full_cmd = [cmd] + args
+ for i, elem in enumerate(full_cmd):
+ if isinstance(elem, unicode):
+ full_cmd[i] = elem.encode(_encoding())
try:
with open(os.devnull, 'wb') as devnull:
- subprocess.check_call([cmd] + args, stderr=devnull,
+ subprocess.check_call(full_cmd, stderr=devnull,
stdout=devnull, stdin=devnull)
except OSError:
return False
@@ -167,7 +172,7 @@ class TestHelper(object):
self.config.read()
self.config['plugins'] = []
- self.config['verbose'] = True
+ self.config['verbose'] = 1
self.config['ui']['color'] = False
self.config['threaded'] = False
diff --git a/test/test_autotag.py b/test/test_autotag.py
index 7e018a4c1..6393a0f2f 100644
--- a/test/test_autotag.py
+++ b/test/test_autotag.py
@@ -789,6 +789,13 @@ class ApplyTest(_common.TestCase, ApplyTestUtil):
self.assertEqual(self.items[0].month, 2)
self.assertEqual(self.items[0].day, 3)
+ def test_data_source_applied(self):
+ my_info = copy.deepcopy(self.info)
+ my_info.data_source = 'MusicBrainz'
+ self._apply(info=my_info)
+
+ self.assertEqual(self.items[0].data_source, 'MusicBrainz')
+
class ApplyCompilationTest(_common.TestCase, ApplyTestUtil):
def setUp(self):
diff --git a/test/test_config_command.py b/test/test_config_command.py
index d48afe2d9..c206c7ac8 100644
--- a/test/test_config_command.py
+++ b/test/test_config_command.py
@@ -10,7 +10,6 @@ from shutil import rmtree
from beets import ui
from beets import config
-from test import _common
from test._common import unittest
from test.helper import TestHelper, capture_stdout
from beets.library import Library
@@ -81,33 +80,22 @@ class ConfigCommandTest(unittest.TestCase, TestHelper):
execlp.assert_called_once_with(
'myeditor', 'myeditor', self.config_path)
- def test_edit_config_with_open(self):
- with _common.system_mock('Darwin'):
+ def test_edit_config_with_automatic_open(self):
+ with patch('beets.util.open_anything') as open:
+ open.return_value = 'please_open'
with patch('os.execlp') as execlp:
self.run_command('config', '-e')
execlp.assert_called_once_with(
- 'open', 'open', '-n', self.config_path)
-
- def test_edit_config_with_xdg_open(self):
- with _common.system_mock('Linux'):
- with patch('os.execlp') as execlp:
- self.run_command('config', '-e')
- execlp.assert_called_once_with(
- 'xdg-open', 'xdg-open', self.config_path)
-
- def test_edit_config_with_windows_exec(self):
- with _common.system_mock('Windows'):
- with patch('os.execlp') as execlp:
- self.run_command('config', '-e')
- execlp.assert_called_once_with(self.config_path, self.config_path)
+ 'please_open', 'please_open', self.config_path)
def test_config_editor_not_found(self):
with self.assertRaises(ui.UserError) as user_error:
with patch('os.execlp') as execlp:
- execlp.side_effect = OSError()
+ execlp.side_effect = OSError('here is problem')
self.run_command('config', '-e')
self.assertIn('Could not edit configuration',
- unicode(user_error.exception.args[0]))
+ unicode(user_error.exception))
+ self.assertIn('here is problem', unicode(user_error.exception))
def test_edit_invalid_config_file(self):
self.lib = Library(':memory:')
diff --git a/test/test_dbcore.py b/test/test_dbcore.py
index dffe9ae75..39867ceb0 100644
--- a/test/test_dbcore.py
+++ b/test/test_dbcore.py
@@ -449,6 +449,7 @@ class SortFromStringsTest(unittest.TestCase):
def test_zero_parts(self):
s = self.sfs([])
self.assertIsInstance(s, dbcore.query.NullSort)
+ self.assertEqual(s, dbcore.query.NullSort())
def test_one_parts(self):
s = self.sfs(['field+'])
@@ -461,17 +462,17 @@ class SortFromStringsTest(unittest.TestCase):
def test_fixed_field_sort(self):
s = self.sfs(['field_one+'])
- self.assertIsInstance(s, dbcore.query.MultipleSort)
- self.assertIsInstance(s.sorts[0], dbcore.query.FixedFieldSort)
+ self.assertIsInstance(s, dbcore.query.FixedFieldSort)
+ self.assertEqual(s, dbcore.query.FixedFieldSort('field_one'))
def test_flex_field_sort(self):
s = self.sfs(['flex_field+'])
- self.assertIsInstance(s, dbcore.query.MultipleSort)
- self.assertIsInstance(s.sorts[0], dbcore.query.SlowFieldSort)
+ self.assertIsInstance(s, dbcore.query.SlowFieldSort)
+ self.assertEqual(s, dbcore.query.SlowFieldSort('flex_field'))
def test_special_sort(self):
s = self.sfs(['some_sort+'])
- self.assertIsInstance(s.sorts[0], TestSort)
+ self.assertIsInstance(s, TestSort)
class ResultsIteratorTest(unittest.TestCase):
diff --git a/test/test_embedart.py b/test/test_embedart.py
index 6347c32d1..729f5853d 100644
--- a/test/test_embedart.py
+++ b/test/test_embedart.py
@@ -16,6 +16,7 @@ from __future__ import (division, absolute_import, print_function,
unicode_literals)
import os.path
+import shutil
from mock import Mock, patch
from test import _common
@@ -41,7 +42,7 @@ def require_artresizer_compare(test):
return wrapper
-class EmbedartCliTest(unittest.TestCase, TestHelper):
+class EmbedartCliTest(_common.TestCase, TestHelper):
small_artpath = os.path.join(_common.RSRC, 'image-2x3.jpg')
abbey_artpath = os.path.join(_common.RSRC, 'abbey.jpg')
@@ -114,6 +115,18 @@ class EmbedartCliTest(unittest.TestCase, TestHelper):
'Image written is not {0}'.format(
self.abbey_similarpath))
+ def test_non_ascii_album_path(self):
+ resource_path = os.path.join(_common.RSRC, 'image.mp3')
+ album = self.add_album_fixture()
+ trackpath = album.items()[0].path
+ albumpath = album.path
+ shutil.copy(resource_path, trackpath.decode('utf-8'))
+
+ self.run_command('extractart', '-n', 'extracted')
+
+ self.assertExists(os.path.join(albumpath.decode('utf-8'),
+ 'extracted.png'))
+
class EmbedartTest(unittest.TestCase):
@patch('beetsplug.embedart.subprocess')
diff --git a/test/test_importer.py b/test/test_importer.py
index 45901bc6a..a9863b926 100644
--- a/test/test_importer.py
+++ b/test/test_importer.py
@@ -625,6 +625,15 @@ class ImportTest(_common.TestCase, ImportHelper):
self.assertIn('No files imported from {0}'.format(import_dir), logs)
+ def test_asis_no_data_source(self):
+ self.assertEqual(self.lib.items().get(), None)
+
+ self.importer.add_choice(importer.action.ASIS)
+ self.importer.run()
+
+ with self.assertRaises(AttributeError):
+ self.lib.items().get().data_source
+
class ImportTracksTest(_common.TestCase, ImportHelper):
"""Test TRACKS and APPLY choice.
diff --git a/test/test_library.py b/test/test_library.py
index d3bfe1373..c3807637e 100644
--- a/test/test_library.py
+++ b/test/test_library.py
@@ -31,7 +31,7 @@ from test._common import unittest
from test._common import item
import beets.library
import beets.mediafile
-import beets.dbcore
+import beets.dbcore.query
from beets import util
from beets import plugins
from beets import config
@@ -240,27 +240,6 @@ class DestinationTest(_common.TestCase):
self.assertFalse('two \\ three' in p)
self.assertFalse('two / three' in p)
- def test_sanitize_unix_replaces_leading_dot(self):
- with _common.platform_posix():
- p = util.sanitize_path(u'one/.two/three')
- self.assertFalse('.' in p)
-
- def test_sanitize_windows_replaces_trailing_dot(self):
- with _common.platform_windows():
- p = util.sanitize_path(u'one/two./three')
- self.assertFalse('.' in p)
-
- def test_sanitize_windows_replaces_illegal_chars(self):
- with _common.platform_windows():
- p = util.sanitize_path(u':*?"<>|')
- self.assertFalse(':' in p)
- self.assertFalse('*' in p)
- self.assertFalse('?' in p)
- self.assertFalse('"' in p)
- self.assertFalse('<' in p)
- self.assertFalse('>' in p)
- self.assertFalse('|' in p)
-
def test_path_with_format(self):
self.lib.path_formats = [('default', '$artist/$album ($format)')]
p = self.i.destination()
@@ -337,11 +316,6 @@ class DestinationTest(_common.TestCase):
]
self.assertEqual(self.i.destination(), np('one/three'))
- def test_sanitize_windows_replaces_trailing_space(self):
- with _common.platform_windows():
- p = util.sanitize_path(u'one/two /three')
- self.assertFalse(' ' in p)
-
def test_get_formatted_does_not_replace_separators(self):
with _common.platform_posix():
name = os.path.join('a', 'b')
@@ -407,25 +381,6 @@ class DestinationTest(_common.TestCase):
p = self.i.destination()
self.assertEqual(p.rsplit(os.path.sep, 1)[1], 'something')
- def test_sanitize_path_works_on_empty_string(self):
- with _common.platform_posix():
- p = util.sanitize_path(u'')
- self.assertEqual(p, u'')
-
- def test_sanitize_with_custom_replace_overrides_built_in_sub(self):
- with _common.platform_posix():
- p = util.sanitize_path(u'a/.?/b', [
- (re.compile(r'foo'), u'bar'),
- ])
- self.assertEqual(p, u'a/.?/b')
-
- def test_sanitize_with_custom_replace_adds_replacements(self):
- with _common.platform_posix():
- p = util.sanitize_path(u'foo/bar', [
- (re.compile(r'foo'), u'bar'),
- ])
- self.assertEqual(p, u'bar/bar')
-
def test_unicode_normalized_nfd_on_mac(self):
instr = unicodedata.normalize('NFC', u'caf\xe9')
self.lib.path_formats = [('default', instr)]
@@ -474,14 +429,6 @@ class DestinationTest(_common.TestCase):
self.assertEqual(self.i.destination(),
np('base/ber/foo'))
- @unittest.skip('unimplemented: #359')
- def test_sanitize_empty_component(self):
- with _common.platform_posix():
- p = util.sanitize_path(u'foo//bar', [
- (re.compile(r'^$'), u'_'),
- ])
- self.assertEqual(p, u'foo/_/bar')
-
@unittest.skip('unimplemented: #359')
def test_destination_with_empty_component(self):
self.lib.directory = 'base'
@@ -505,6 +452,22 @@ class DestinationTest(_common.TestCase):
self.assertEqual(self.i.destination(),
np('base/one/_.mp3'))
+ @unittest.skip('unimplemented: #496')
+ def test_truncation_does_not_conflict_with_replacement(self):
+ # Use a replacement that should always replace the last X in any
+ # path component with a Z.
+ self.lib.replacements = [
+ (re.compile(r'X$'), u'Z'),
+ ]
+
+ # Construct an item whose untruncated path ends with a Y but whose
+ # truncated version ends with an X.
+ self.i.title = 'X' * 300 + 'Y'
+
+ # The final path should reflect the replacement.
+ dest = self.i.destination()
+ self.assertTrue('XZ' in dest)
+
class ItemFormattedMappingTest(_common.LibTestCase):
def test_formatted_item_value(self):
@@ -700,49 +663,6 @@ class DisambiguationTest(_common.TestCase, PathFormattingMixin):
self._assert_dest('/base/foo [foo_bar]/the title', self.i1)
-class PathConversionTest(_common.TestCase):
- def test_syspath_windows_format(self):
- with _common.platform_windows():
- path = os.path.join('a', 'b', 'c')
- outpath = util.syspath(path)
- self.assertTrue(isinstance(outpath, unicode))
- self.assertTrue(outpath.startswith(u'\\\\?\\'))
-
- def test_syspath_windows_format_unc_path(self):
- # The \\?\ prefix on Windows behaves differently with UNC
- # (network share) paths.
- path = '\\\\server\\share\\file.mp3'
- with _common.platform_windows():
- outpath = util.syspath(path)
- self.assertTrue(isinstance(outpath, unicode))
- self.assertEqual(outpath, u'\\\\?\\UNC\\server\\share\\file.mp3')
-
- def test_syspath_posix_unchanged(self):
- with _common.platform_posix():
- path = os.path.join('a', 'b', 'c')
- outpath = util.syspath(path)
- self.assertEqual(path, outpath)
-
- def _windows_bytestring_path(self, path):
- old_gfse = sys.getfilesystemencoding
- sys.getfilesystemencoding = lambda: 'mbcs'
- try:
- with _common.platform_windows():
- return util.bytestring_path(path)
- finally:
- sys.getfilesystemencoding = old_gfse
-
- def test_bytestring_path_windows_encodes_utf8(self):
- path = u'caf\xe9'
- outpath = self._windows_bytestring_path(path)
- self.assertEqual(path, outpath.decode('utf8'))
-
- def test_bytesting_path_windows_removes_magic_prefix(self):
- path = u'\\\\?\\C:\\caf\xe9'
- outpath = self._windows_bytestring_path(path)
- self.assertEqual(outpath, u'C:\\caf\xe9'.encode('utf8'))
-
-
class PluginDestinationTest(_common.TestCase):
def setUp(self):
super(PluginDestinationTest, self).setUp()
@@ -1004,23 +924,6 @@ class PathStringTest(_common.TestCase):
self.assert_(isinstance(alb.artpath, bytes))
-class PathTruncationTest(_common.TestCase):
- def test_truncate_bytestring(self):
- with _common.platform_posix():
- p = util.truncate_path('abcde/fgh', 4)
- self.assertEqual(p, 'abcd/fgh')
-
- def test_truncate_unicode(self):
- with _common.platform_posix():
- p = util.truncate_path(u'abcde/fgh', 4)
- self.assertEqual(p, u'abcd/fgh')
-
- def test_truncate_preserves_extension(self):
- with _common.platform_posix():
- p = util.truncate_path(u'abcde/fgh.ext', 5)
- self.assertEqual(p, u'abcde/f.ext')
-
-
class MtimeTest(_common.TestCase):
def setUp(self):
super(MtimeTest, self).setUp()
@@ -1085,7 +988,7 @@ class TemplateTest(_common.LibTestCase):
self.assertEqual(self.i.evaluate_template('$foo'), 'baz')
def test_album_and_item_format(self):
- config['list_format_album'] = u'foö $foo'
+ config['format_album'] = u'foö $foo'
album = beets.library.Album()
album.foo = 'bar'
album.tagada = 'togodo'
@@ -1094,7 +997,7 @@ class TemplateTest(_common.LibTestCase):
self.assertEqual(unicode(album), u"foö bar")
self.assertEqual(str(album), b"fo\xc3\xb6 bar")
- config['list_format_item'] = 'bar $foo'
+ config['format_item'] = 'bar $foo'
item = beets.library.Item()
item.foo = 'bar'
item.tagada = 'togodo'
@@ -1172,10 +1075,32 @@ class ItemReadTest(unittest.TestCase):
item.read('/thisfiledoesnotexist')
+class FilesizeTest(unittest.TestCase, TestHelper):
+ def setUp(self):
+ self.setup_beets()
+
+ def tearDown(self):
+ self.teardown_beets()
+
+ def test_filesize(self):
+ item = self.add_item_fixture()
+ self.assertNotEquals(item.filesize, 0)
+
+ def test_nonexistent_file(self):
+ item = beets.library.Item()
+ self.assertEqual(item.filesize, 0)
+
+
class ParseQueryTest(unittest.TestCase):
def test_parse_invalid_query_string(self):
- with self.assertRaises(beets.dbcore.InvalidQueryError):
+ with self.assertRaises(beets.dbcore.InvalidQueryError) as raised:
beets.library.parse_query_string('foo"', None)
+ self.assertIsInstance(raised.exception,
+ beets.dbcore.query.ParsingError)
+
+ def test_parse_bytes(self):
+ with self.assertRaises(AssertionError):
+ beets.library.parse_query_string(b"query", None)
def suite():
diff --git a/test/test_logging.py b/test/test_logging.py
index 864fd021a..a4e2cfbe7 100644
--- a/test/test_logging.py
+++ b/test/test_logging.py
@@ -2,11 +2,15 @@
from __future__ import (division, absolute_import, print_function,
unicode_literals)
+import sys
import logging as log
from StringIO import StringIO
import beets.logging as blog
+from beets import plugins, ui
+import beetsplug
from test._common import unittest, TestCase
+from test import helper
class LoggingTest(TestCase):
@@ -37,6 +41,106 @@ class LoggingTest(TestCase):
self.assertTrue(stream.getvalue(), "foo oof baz")
+class LoggingLevelTest(unittest.TestCase, helper.TestHelper):
+ class DummyModule(object):
+ class DummyPlugin(plugins.BeetsPlugin):
+ def __init__(self):
+ plugins.BeetsPlugin.__init__(self, 'dummy')
+ self.import_stages = [self.import_stage]
+ self.register_listener('dummy_event', self.listener)
+
+ def log_all(self, name):
+ self._log.debug('debug ' + name)
+ self._log.info('info ' + name)
+ self._log.warning('warning ' + name)
+
+ def commands(self):
+ cmd = ui.Subcommand('dummy')
+ cmd.func = lambda _, __, ___: self.log_all('cmd')
+ return (cmd,)
+
+ def import_stage(self, session, task):
+ self.log_all('import_stage')
+
+ def listener(self):
+ self.log_all('listener')
+
+ def setUp(self):
+ sys.modules['beetsplug.dummy'] = self.DummyModule
+ beetsplug.dummy = self.DummyModule
+ self.setup_beets()
+ self.load_plugins('dummy')
+
+ def tearDown(self):
+ self.unload_plugins()
+ self.teardown_beets()
+ del beetsplug.dummy
+ sys.modules.pop('beetsplug.dummy')
+
+ def test_command_logging(self):
+ self.config['verbose'] = 0
+ with helper.capture_log() as logs:
+ self.run_command('dummy')
+ self.assertIn('dummy: warning cmd', logs)
+ self.assertIn('dummy: info cmd', logs)
+ self.assertNotIn('dummy: debug cmd', logs)
+
+ for level in (1, 2):
+ self.config['verbose'] = level
+ with helper.capture_log() as logs:
+ self.run_command('dummy')
+ self.assertIn('dummy: warning cmd', logs)
+ self.assertIn('dummy: info cmd', logs)
+ self.assertIn('dummy: debug cmd', logs)
+
+ def test_listener_logging(self):
+ self.config['verbose'] = 0
+ with helper.capture_log() as logs:
+ plugins.send('dummy_event')
+ self.assertIn('dummy: warning listener', logs)
+ self.assertNotIn('dummy: info listener', logs)
+ self.assertNotIn('dummy: debug listener', logs)
+
+ self.config['verbose'] = 1
+ with helper.capture_log() as logs:
+ plugins.send('dummy_event')
+ self.assertIn('dummy: warning listener', logs)
+ self.assertIn('dummy: info listener', logs)
+ self.assertNotIn('dummy: debug listener', logs)
+
+ self.config['verbose'] = 2
+ with helper.capture_log() as logs:
+ plugins.send('dummy_event')
+ self.assertIn('dummy: warning listener', logs)
+ self.assertIn('dummy: info listener', logs)
+ self.assertIn('dummy: debug listener', logs)
+
+ def test_import_stage_logging(self):
+ self.config['verbose'] = 0
+ with helper.capture_log() as logs:
+ importer = self.create_importer()
+ importer.run()
+ self.assertIn('dummy: warning import_stage', logs)
+ self.assertNotIn('dummy: info import_stage', logs)
+ self.assertNotIn('dummy: debug import_stage', logs)
+
+ self.config['verbose'] = 1
+ with helper.capture_log() as logs:
+ importer = self.create_importer()
+ importer.run()
+ self.assertIn('dummy: warning import_stage', logs)
+ self.assertIn('dummy: info import_stage', logs)
+ self.assertNotIn('dummy: debug import_stage', logs)
+
+ self.config['verbose'] = 2
+ with helper.capture_log() as logs:
+ importer = self.create_importer()
+ importer.run()
+ self.assertIn('dummy: warning import_stage', logs)
+ self.assertIn('dummy: info import_stage', logs)
+ self.assertIn('dummy: debug import_stage', logs)
+
+
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
diff --git a/test/test_mb.py b/test/test_mb.py
index 3e5f721ff..c1c93bbdc 100644
--- a/test/test_mb.py
+++ b/test/test_mb.py
@@ -316,6 +316,11 @@ class MBAlbumInfoTest(_common.TestCase):
self.assertEqual(track.artist_sort, 'TRACK ARTIST SORT NAME')
self.assertEqual(track.artist_credit, 'TRACK ARTIST CREDIT')
+ def test_data_source(self):
+ release = self._make_release()
+ d = mb.album_info(release)
+ self.assertEqual(d.data_source, 'MusicBrainz')
+
class ParseIDTest(_common.TestCase):
def test_parse_id_correct(self):
diff --git a/test/test_mbsync.py b/test/test_mbsync.py
index fc37fe8c3..ff6e01cf3 100644
--- a/test/test_mbsync.py
+++ b/test/test_mbsync.py
@@ -73,8 +73,8 @@ class MbsyncCliTest(unittest.TestCase, TestHelper):
self.assertEqual(album.album, 'album info')
def test_message_when_skipping(self):
- config['list_format_item'] = '$artist - $album - $title'
- config['list_format_album'] = '$albumartist - $album'
+ config['format_item'] = '$artist - $album - $title'
+ config['format_album'] = '$albumartist - $album'
# Test album with no mb_albumid.
# The default format for an album include $albumartist so
@@ -99,6 +99,10 @@ class MbsyncCliTest(unittest.TestCase, TestHelper):
e = "mbsync: Skipping album with no mb_albumid: 'album info'"
self.assertEqual(e, logs[0])
+ # restore the config
+ config['format_item'] = '$artist - $album - $title'
+ config['format_album'] = '$albumartist - $album'
+
# Test singleton with no mb_trackid.
# The default singleton format includes $artist and $album
# so we need to stub them here
diff --git a/test/test_mediafile.py b/test/test_mediafile.py
index 8b23d1cee..cd149e7e4 100644
--- a/test/test_mediafile.py
+++ b/test/test_mediafile.py
@@ -28,7 +28,7 @@ from test import _common
from test._common import unittest
from beets.mediafile import MediaFile, MediaField, Image, \
MP3DescStorageStyle, StorageStyle, MP4StorageStyle, \
- ASFStorageStyle, ImageType
+ ASFStorageStyle, ImageType, CoverArtField
from beets.library import Item
from beets.plugins import BeetsPlugin
@@ -161,6 +161,13 @@ class ImageStructureTestMixin(ArtTestMixin):
mediafile = MediaFile(mediafile.path)
self.assertEqual(len(mediafile.images), 0)
+ def test_guess_cover(self):
+ mediafile = self._mediafile_fixture('image')
+ self.assertEqual(len(mediafile.images), 2)
+ cover = CoverArtField.guess_cover_image(mediafile.images)
+ self.assertEqual(cover.desc, 'album cover')
+ self.assertEqual(mediafile.art, cover.data)
+
def assertExtendedImageAttributes(self, image, **kwargs):
"""Ignore extended image attributes in the base tests.
"""
@@ -662,7 +669,7 @@ class ReadWriteTestBase(ArtTestMixin, GenreListTestMixin,
errors.append('Tag %s does not exist' % key)
else:
if value2 != value:
- errors.append('Tag %s: %s != %s' % (key, value2, value))
+ errors.append('Tag %s: %r != %r' % (key, value2, value))
if any(errors):
errors = ['Tags did not match'] + errors
self.fail('\n '.join(errors))
@@ -758,6 +765,10 @@ class MP4Test(ReadWriteTestBase, PartialTestMixin,
with self.assertRaises(ValueError):
mediafile.images = [Image(data=self.tiff_data)]
+ def test_guess_cover(self):
+ # There is no metadata associated with images, we pick one at random
+ pass
+
class AlacTest(ReadWriteTestBase, unittest.TestCase):
extension = 'alac.m4a'
diff --git a/test/test_permissions.py b/test/test_permissions.py
index 1e16a1c34..20e33b7d2 100644
--- a/test/test_permissions.py
+++ b/test/test_permissions.py
@@ -5,7 +5,9 @@ from __future__ import (division, absolute_import, print_function,
from test._common import unittest
from test.helper import TestHelper
-from beetsplug.permissions import check_permissions, convert_perm
+from beetsplug.permissions import (check_permissions,
+ convert_perm,
+ dirs_in_library)
class PermissionsPluginTest(unittest.TestCase, TestHelper):
@@ -14,7 +16,8 @@ class PermissionsPluginTest(unittest.TestCase, TestHelper):
self.load_plugins('permissions')
self.config['permissions'] = {
- 'file': 777}
+ 'file': 777,
+ 'dir': 777}
def tearDown(self):
self.teardown_beets()
@@ -24,23 +27,45 @@ class PermissionsPluginTest(unittest.TestCase, TestHelper):
self.importer = self.create_importer()
self.importer.run()
item = self.lib.items().get()
- config_perm = self.config['permissions']['file'].get()
- config_perm = convert_perm(config_perm)
- self.assertTrue(check_permissions(item.path, config_perm))
+ file_perm = self.config['permissions']['file'].get()
+ file_perm = convert_perm(file_perm)
+
+ dir_perm = self.config['permissions']['dir'].get()
+ dir_perm = convert_perm(dir_perm)
+
+ music_dirs = dirs_in_library(self.config['directory'].get(),
+ item.path)
+
+ self.assertTrue(check_permissions(item.path, file_perm))
self.assertFalse(check_permissions(item.path, convert_perm(644)))
+ for path in music_dirs:
+ self.assertTrue(check_permissions(path, dir_perm))
+ self.assertFalse(check_permissions(path, convert_perm(644)))
+
def test_permissions_on_item_imported(self):
self.config['import']['singletons'] = True
self.importer = self.create_importer()
self.importer.run()
item = self.lib.items().get()
- config_perm = self.config['permissions']['file'].get()
- config_perm = convert_perm(config_perm)
- self.assertTrue(check_permissions(item.path, config_perm))
+ file_perm = self.config['permissions']['file'].get()
+ file_perm = convert_perm(file_perm)
+
+ dir_perm = self.config['permissions']['dir'].get()
+ dir_perm = convert_perm(dir_perm)
+
+ music_dirs = dirs_in_library(self.config['directory'].get(),
+ item.path)
+
+ self.assertTrue(check_permissions(item.path, file_perm))
self.assertFalse(check_permissions(item.path, convert_perm(644)))
+ for path in music_dirs:
+ self.assertTrue(check_permissions(path, dir_perm))
+ self.assertFalse(check_permissions(path, convert_perm(644)))
+
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
diff --git a/test/test_plugins.py b/test/test_plugins.py
index d46c5bd5d..2e8bedca1 100644
--- a/test/test_plugins.py
+++ b/test/test_plugins.py
@@ -16,8 +16,9 @@ from __future__ import (division, absolute_import, print_function,
unicode_literals)
import os
-from mock import patch
+from mock import patch, Mock
import shutil
+import itertools
from beets.importer import SingletonImportTask, SentinelImportTask, \
ArchiveImportTask
@@ -35,6 +36,7 @@ class TestHelper(helper.TestHelper):
def setup_plugin_loader(self):
# FIXME the mocking code is horrific, but this is the lowest and
# earliest level of the plugin mechanism we can hook into.
+ self.load_plugins()
self._plugin_loader_patch = patch('beets.plugins.load_plugins')
self._plugin_classes = set()
load_plugins = self._plugin_loader_patch.start()
@@ -56,7 +58,6 @@ class ItemTypesTest(unittest.TestCase, TestHelper):
def setUp(self):
self.setup_plugin_loader()
- self.setup_beets()
def tearDown(self):
self.teardown_plugin_loader()
@@ -95,7 +96,7 @@ class ItemWriteTest(unittest.TestCase, TestHelper):
class EventListenerPlugin(plugins.BeetsPlugin):
pass
- self.event_listener_plugin = EventListenerPlugin
+ self.event_listener_plugin = EventListenerPlugin()
self.register_plugin(EventListenerPlugin)
def tearDown(self):
@@ -298,19 +299,105 @@ class ListenersTest(unittest.TestCase, TestHelper):
pass
d = DummyPlugin()
- self.assertEqual(DummyPlugin.listeners['cli_exit'], [d.dummy])
+ self.assertEqual(DummyPlugin._raw_listeners['cli_exit'], [d.dummy])
d2 = DummyPlugin()
- DummyPlugin.register_listener('cli_exit', d.dummy)
- self.assertEqual(DummyPlugin.listeners['cli_exit'],
+ self.assertEqual(DummyPlugin._raw_listeners['cli_exit'],
[d.dummy, d2.dummy])
- @DummyPlugin.listen('cli_exit')
- def dummy(lib):
- pass
+ d.register_listener('cli_exit', d2.dummy)
+ self.assertEqual(DummyPlugin._raw_listeners['cli_exit'],
+ [d.dummy, d2.dummy])
- self.assertEqual(DummyPlugin.listeners['cli_exit'],
- [d.dummy, d2.dummy, dummy])
+ @patch('beets.plugins.find_plugins')
+ @patch('beets.plugins.inspect')
+ def test_events_called(self, mock_inspect, mock_find_plugins):
+ mock_inspect.getargspec.return_value = None
+
+ class DummyPlugin(plugins.BeetsPlugin):
+ def __init__(self):
+ super(DummyPlugin, self).__init__()
+ self.foo = Mock(__name__=b'foo')
+ self.register_listener('event_foo', self.foo)
+ self.bar = Mock(__name__=b'bar')
+ self.register_listener('event_bar', self.bar)
+
+ d = DummyPlugin()
+ mock_find_plugins.return_value = d,
+
+ plugins.send('event')
+ d.foo.assert_has_calls([])
+ d.bar.assert_has_calls([])
+
+ plugins.send('event_foo', var="tagada")
+ d.foo.assert_called_once_with(var="tagada")
+ d.bar.assert_has_calls([])
+
+ @patch('beets.plugins.find_plugins')
+ def test_listener_params(self, mock_find_plugins):
+ test = self
+
+ class DummyPlugin(plugins.BeetsPlugin):
+ def __init__(self):
+ super(DummyPlugin, self).__init__()
+ for i in itertools.count(1):
+ try:
+ meth = getattr(self, 'dummy{0}'.format(i))
+ except AttributeError:
+ break
+ self.register_listener('event{0}'.format(i), meth)
+
+ def dummy1(self, foo):
+ test.assertEqual(foo, 5)
+
+ def dummy2(self, foo=None):
+ test.assertEqual(foo, 5)
+
+ def dummy3(self):
+ # argument cut off
+ pass
+
+ def dummy4(self, bar=None):
+ # argument cut off
+ pass
+
+ def dummy5(self, bar):
+ test.assertFalse(True)
+
+ # more complex exmaples
+
+ def dummy6(self, foo, bar=None):
+ test.assertEqual(foo, 5)
+ test.assertEqual(bar, None)
+
+ def dummy7(self, foo, **kwargs):
+ test.assertEqual(foo, 5)
+ test.assertEqual(kwargs, {})
+
+ def dummy8(self, foo, bar, **kwargs):
+ test.assertFalse(True)
+
+ def dummy9(self, **kwargs):
+ test.assertEqual(kwargs, {"foo": 5})
+
+ d = DummyPlugin()
+ mock_find_plugins.return_value = d,
+
+ plugins.send('event1', foo=5)
+ plugins.send('event2', foo=5)
+ plugins.send('event3', foo=5)
+ plugins.send('event4', foo=5)
+
+ with self.assertRaises(TypeError):
+ plugins.send('event5', foo=5)
+
+ plugins.send('event6', foo=5)
+ plugins.send('event7', foo=5)
+
+ with self.assertRaises(TypeError):
+ plugins.send('event8', foo=5)
+
+ plugins.send('event9', foo=5)
def suite():
diff --git a/test/test_query.py b/test/test_query.py
index a32d8d60d..6d8d744fe 100644
--- a/test/test_query.py
+++ b/test/test_query.py
@@ -17,6 +17,8 @@
from __future__ import (division, absolute_import, print_function,
unicode_literals)
+from functools import partial
+
from test import _common
from test._common import unittest
from test import helper
@@ -24,7 +26,8 @@ from test import helper
import beets.library
from beets import dbcore
from beets.dbcore import types
-from beets.dbcore.query import NoneQuery, InvalidQueryArgumentTypeError
+from beets.dbcore.query import (NoneQuery, ParsingError,
+ InvalidQueryArgumentTypeError)
from beets.library import Library, Item
@@ -57,6 +60,16 @@ class AnyFieldQueryTest(_common.LibTestCase):
dbcore.query.SubstringQuery)
self.assertEqual(self.lib.items(q).get(), None)
+ def test_eq(self):
+ q1 = dbcore.query.AnyFieldQuery('foo', ['bar'],
+ dbcore.query.SubstringQuery)
+ q2 = dbcore.query.AnyFieldQuery('foo', ['bar'],
+ dbcore.query.SubstringQuery)
+ self.assertEqual(q1, q2)
+
+ q2.query_class = None
+ self.assertNotEqual(q1, q2)
+
class AssertsMixin(object):
def assert_items_matched(self, results, titles):
@@ -290,7 +303,7 @@ class GetTest(DummyDataTestCase):
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)
+ self.assertIsInstance(raised.exception, ParsingError)
class MatchTest(_common.TestCase):
@@ -341,6 +354,16 @@ class MatchTest(_common.TestCase):
def test_open_range(self):
dbcore.query.NumericQuery('bitrate', '100000..')
+ def test_eq(self):
+ q1 = dbcore.query.MatchQuery('foo', 'bar')
+ q2 = dbcore.query.MatchQuery('foo', 'bar')
+ q3 = dbcore.query.MatchQuery('foo', 'baz')
+ q4 = dbcore.query.StringFieldQuery('foo', 'bar')
+ self.assertEqual(q1, q2)
+ self.assertNotEqual(q1, q3)
+ self.assertNotEqual(q1, q4)
+ self.assertNotEqual(q3, q4)
+
class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin):
def setUp(self):
@@ -460,6 +483,26 @@ class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin):
results = self.lib.albums(q)
self.assert_albums_matched(results, ['album with backslash'])
+ def test_case_sensitivity(self):
+ self.add_album(path='/A/B/C2.mp3', title='caps path')
+
+ makeq = partial(beets.library.PathQuery, 'path', '/A/B')
+
+ results = self.lib.items(makeq(case_sensitive=True))
+ self.assert_items_matched(results, ['caps path'])
+
+ results = self.lib.items(makeq(case_sensitive=False))
+ self.assert_items_matched(results, ['path item', 'caps path'])
+
+ # test platform-aware default sensitivity
+ with _common.system_mock('Darwin'):
+ q = makeq()
+ self.assertEqual(q.case_sensitive, True)
+
+ with _common.system_mock('Windows'):
+ q = makeq()
+ self.assertEqual(q.case_sensitive, False)
+
class IntQueryTest(unittest.TestCase, TestHelper):
diff --git a/test/test_replaygain.py b/test/test_replaygain.py
index 64d65b006..3eb93520f 100644
--- a/test/test_replaygain.py
+++ b/test/test_replaygain.py
@@ -25,7 +25,7 @@ try:
import gi
gi.require_version('Gst', '1.0')
GST_AVAILABLE = True
-except ImportError, ValueError:
+except (ImportError, ValueError):
GST_AVAILABLE = False
if any(has_program(cmd, ['-v']) for cmd in ['mp3gain', 'aacgain']):
@@ -33,6 +33,11 @@ if any(has_program(cmd, ['-v']) for cmd in ['mp3gain', 'aacgain']):
else:
GAIN_PROG_AVAILABLE = False
+if has_program('bs1770gain', ['--replaygain']):
+ LOUDNESS_PROG_AVAILABLE = True
+else:
+ LOUDNESS_PROG_AVAILABLE = False
+
class ReplayGainCliTestBase(TestHelper):
@@ -42,9 +47,18 @@ class ReplayGainCliTestBase(TestHelper):
try:
self.load_plugins('replaygain')
except:
- self.teardown_beets()
- self.unload_plugins()
- raise
+ import sys
+ # store exception info so an error in teardown does not swallow it
+ exc_info = sys.exc_info()
+ try:
+ self.teardown_beets()
+ self.unload_plugins()
+ except:
+ # if load_plugins() failed then setup is incomplete and
+ # teardown operations may fail. In particular # {Item,Album}
+ # may not have the _original_types attribute in unload_plugins
+ pass
+ raise exc_info[1], None, exc_info[2]
self.config['replaygain']['backend'] = self.backend
album = self.add_album_fixture(2)
@@ -123,6 +137,11 @@ class ReplayGainCmdCliTest(ReplayGainCliTestBase, unittest.TestCase):
backend = u'command'
+@unittest.skipIf(not LOUDNESS_PROG_AVAILABLE, 'bs1770gain cannot be found')
+class ReplayGainLdnsCliTest(ReplayGainCliTestBase, unittest.TestCase):
+ backend = u'bs1770gain'
+
+
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
diff --git a/test/test_smartplaylist.py b/test/test_smartplaylist.py
new file mode 100644
index 000000000..ddfa8a156
--- /dev/null
+++ b/test/test_smartplaylist.py
@@ -0,0 +1,222 @@
+# This file is part of beets.
+# Copyright 2015, Bruno Cauet.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+from __future__ import (division, absolute_import, print_function,
+ unicode_literals)
+
+from os import path, remove
+from tempfile import mkdtemp
+from shutil import rmtree
+
+from mock import Mock, MagicMock
+
+from beetsplug.smartplaylist import SmartPlaylistPlugin
+from beets.library import Item, Album, parse_query_string
+from beets.dbcore import OrQuery
+from beets.dbcore.query import NullSort, MultipleSort, FixedFieldSort
+from beets.util import syspath
+from beets.ui import UserError
+from beets import config
+
+from test._common import unittest
+from test.helper import TestHelper
+
+
+class SmartPlaylistTest(unittest.TestCase):
+ def test_build_queries(self):
+ spl = SmartPlaylistPlugin()
+ self.assertEqual(spl._matched_playlists, None)
+ self.assertEqual(spl._unmatched_playlists, None)
+
+ config['smartplaylist']['playlists'].set([])
+ spl.build_queries()
+ self.assertEqual(spl._matched_playlists, set())
+ self.assertEqual(spl._unmatched_playlists, set())
+
+ config['smartplaylist']['playlists'].set([
+ {'name': 'foo',
+ 'query': 'FOO foo'},
+ {'name': 'bar',
+ 'album_query': ['BAR bar1', 'BAR bar2']},
+ {'name': 'baz',
+ 'query': 'BAZ baz',
+ 'album_query': 'BAZ baz'}
+ ])
+ spl.build_queries()
+ self.assertEqual(spl._matched_playlists, set())
+ foo_foo = parse_query_string('FOO foo', Item)
+ baz_baz = parse_query_string('BAZ baz', Item)
+ baz_baz2 = parse_query_string('BAZ baz', Album)
+ bar_bar = OrQuery((parse_query_string('BAR bar1', Album)[0],
+ parse_query_string('BAR bar2', Album)[0]))
+ self.assertEqual(spl._unmatched_playlists, set([
+ ('foo', foo_foo, (None, None)),
+ ('baz', baz_baz, baz_baz2),
+ ('bar', (None, None), (bar_bar, None)),
+ ]))
+
+ def test_build_queries_with_sorts(self):
+ spl = SmartPlaylistPlugin()
+ config['smartplaylist']['playlists'].set([
+ {'name': 'no_sort', 'query': 'foo'},
+ {'name': 'one_sort', 'query': 'foo year+'},
+ {'name': 'only_empty_sorts', 'query': ['foo', 'bar']},
+ {'name': 'one_non_empty_sort', 'query': ['foo year+', 'bar']},
+ {'name': 'multiple_sorts', 'query': ['foo year+', 'bar genre-']},
+ {'name': 'mixed', 'query': ['foo year+', 'bar', 'baz genre+ id-']}
+ ])
+
+ spl.build_queries()
+ sorts = dict((name, sort)
+ for name, (_, sort), _ in spl._unmatched_playlists)
+
+ asseq = self.assertEqual # less cluttered code
+ S = FixedFieldSort # short cut since we're only dealing with this
+ asseq(sorts["no_sort"], NullSort())
+ asseq(sorts["one_sort"], S('year'))
+ asseq(sorts["only_empty_sorts"], None)
+ asseq(sorts["one_non_empty_sort"], S('year'))
+ asseq(sorts["multiple_sorts"],
+ MultipleSort([S('year'), S('genre', False)]))
+ asseq(sorts["mixed"],
+ MultipleSort([S('year'), S('genre'), S('id', False)]))
+
+ def test_matches(self):
+ spl = SmartPlaylistPlugin()
+
+ a = MagicMock(Album)
+ i = MagicMock(Item)
+
+ self.assertFalse(spl.matches(i, None, None))
+ self.assertFalse(spl.matches(a, None, None))
+
+ query = Mock()
+ query.match.side_effect = {i: True}.__getitem__
+ self.assertTrue(spl.matches(i, query, None))
+ self.assertFalse(spl.matches(a, query, None))
+
+ a_query = Mock()
+ a_query.match.side_effect = {a: True}.__getitem__
+ self.assertFalse(spl.matches(i, None, a_query))
+ self.assertTrue(spl.matches(a, None, a_query))
+
+ self.assertTrue(spl.matches(i, query, a_query))
+ self.assertTrue(spl.matches(a, query, a_query))
+
+ def test_db_changes(self):
+ spl = SmartPlaylistPlugin()
+
+ nones = None, None
+ pl1 = '1', ('q1', None), nones
+ pl2 = '2', ('q2', None), nones
+ pl3 = '3', ('q3', None), nones
+
+ spl._unmatched_playlists = set([pl1, pl2, pl3])
+ spl._matched_playlists = set()
+
+ spl.matches = Mock(return_value=False)
+ spl.db_change(None, "nothing")
+ self.assertEqual(spl._unmatched_playlists, set([pl1, pl2, pl3]))
+ self.assertEqual(spl._matched_playlists, set())
+
+ spl.matches.side_effect = lambda _, q, __: q == 'q3'
+ spl.db_change(None, "matches 3")
+ self.assertEqual(spl._unmatched_playlists, set([pl1, pl2]))
+ self.assertEqual(spl._matched_playlists, set([pl3]))
+
+ spl.matches.side_effect = lambda _, q, __: q == 'q1'
+ spl.db_change(None, "matches 3")
+ self.assertEqual(spl._matched_playlists, set([pl1, pl3]))
+ self.assertEqual(spl._unmatched_playlists, set([pl2]))
+
+ def test_playlist_update(self):
+ spl = SmartPlaylistPlugin()
+
+ i = Mock(path='/tagada.mp3')
+ i.evaluate_template.side_effect = lambda x, _: x
+ q = Mock()
+ a_q = Mock()
+ lib = Mock()
+ lib.items.return_value = [i]
+ lib.albums.return_value = []
+ pl = 'my_playlist.m3u', (q, None), (a_q, None)
+ spl._matched_playlists = [pl]
+
+ dir = mkdtemp()
+ config['smartplaylist']['relative_to'] = False
+ config['smartplaylist']['playlist_dir'] = dir
+ try:
+ spl.update_playlists(lib)
+ except Exception:
+ rmtree(dir)
+ raise
+
+ lib.items.assert_called_once_with(q, None)
+ lib.albums.assert_called_once_with(a_q, None)
+
+ m3u_filepath = path.join(dir, pl[0])
+ self.assertTrue(path.exists(m3u_filepath))
+ with open(syspath(m3u_filepath), 'r') as f:
+ content = f.read()
+ rmtree(dir)
+
+ self.assertEqual(content, "/tagada.mp3\n")
+
+
+class SmartPlaylistCLITest(unittest.TestCase, TestHelper):
+ def setUp(self):
+ self.setup_beets()
+
+ self.item = self.add_item()
+ config['smartplaylist']['playlists'].set([
+ {'name': 'my_playlist.m3u',
+ 'query': self.item.title},
+ {'name': 'all.m3u',
+ 'query': ''}
+ ])
+ config['smartplaylist']['playlist_dir'].set(self.temp_dir)
+ self.load_plugins('smartplaylist')
+
+ def tearDown(self):
+ self.unload_plugins()
+ self.teardown_beets()
+
+ def test_splupdate(self):
+ with self.assertRaises(UserError):
+ self.run_with_output('splupdate', 'tagada')
+
+ self.run_with_output('splupdate', 'my_playlist')
+ m3u_path = path.join(self.temp_dir, 'my_playlist.m3u')
+ self.assertTrue(path.exists(m3u_path))
+ with open(m3u_path, 'r') as f:
+ self.assertEqual(f.read(), self.item.path + b"\n")
+ remove(m3u_path)
+
+ self.run_with_output('splupdate', 'my_playlist.m3u')
+ with open(m3u_path, 'r') as f:
+ self.assertEqual(f.read(), self.item.path + b"\n")
+ remove(m3u_path)
+
+ self.run_with_output('splupdate')
+ for name in ('my_playlist.m3u', 'all.m3u'):
+ with open(path.join(self.temp_dir, name), 'r') as f:
+ self.assertEqual(f.read(), self.item.path + b"\n")
+
+
+def suite():
+ return unittest.TestLoader().loadTestsFromName(__name__)
+
+
+if __name__ == b'__main__':
+ unittest.main(defaultTest='suite')
diff --git a/test/test_spotify.py b/test/test_spotify.py
index 3d4d75bde..3025163bb 100644
--- a/test/test_spotify.py
+++ b/test/test_spotify.py
@@ -17,7 +17,7 @@ class ArgumentsMock(object):
def __init__(self, mode, show_failures):
self.mode = mode
self.show_failures = show_failures
- self.verbose = True
+ self.verbose = 1
def _params(url):
diff --git a/test/test_the.py b/test/test_the.py
index 1b33c390e..e04db1aec 100644
--- a/test/test_the.py
+++ b/test/test_the.py
@@ -44,7 +44,6 @@ class ThePluginTest(_common.TestCase):
def test_template_function_with_defaults(self):
ThePlugin().patterns = [PATTERN_THE, PATTERN_A]
- ThePlugin().format = FORMAT
self.assertEqual(ThePlugin().the_template_func('The The'), 'The, The')
self.assertEqual(ThePlugin().the_template_func('An A'), 'A, An')
diff --git a/test/test_ui.py b/test/test_ui.py
index 07add2fcd..14cb4081f 100644
--- a/test/test_ui.py
+++ b/test/test_ui.py
@@ -22,7 +22,9 @@ import shutil
import re
import subprocess
import platform
+from copy import deepcopy
+from mock import patch
from test import _common
from test._common import unittest
from test.helper import capture_stdout, has_program, TestHelper, control_stdin
@@ -968,8 +970,45 @@ class ShowChangeTest(_common.TestCase):
self.items[0].title = u''
self.items[0].path = u'/path/to/caf\xe9.mp3'.encode('utf8')
msg = re.sub(r' +', ' ', self._show_change())
- self.assertTrue(u'caf\xe9.mp3 -> the title' in msg
- or u'caf.mp3 ->' in msg)
+ self.assertTrue(u'caf\xe9.mp3 -> the title' in msg or
+ u'caf.mp3 ->' in msg)
+
+
+class SummarizeItemsTest(_common.TestCase):
+ def setUp(self):
+ super(SummarizeItemsTest, self).setUp()
+ item = library.Item()
+ item.bitrate = 4321
+ item.length = 10 * 60 + 54
+ item.format = "F"
+ self.item = item
+ fsize_mock = patch('beets.library.Item.try_filesize').start()
+ fsize_mock.return_value = 987
+
+ def test_summarize_item(self):
+ summary = commands.summarize_items([], True)
+ self.assertEqual(summary, "")
+
+ summary = commands.summarize_items([self.item], True)
+ self.assertEqual(summary, "F, 4kbps, 10:54, 987.0 B")
+
+ def test_summarize_items(self):
+ summary = commands.summarize_items([], False)
+ self.assertEqual(summary, "0 items")
+
+ summary = commands.summarize_items([self.item], False)
+ self.assertEqual(summary, "1 items, F, 4kbps, 10:54, 987.0 B")
+
+ i2 = deepcopy(self.item)
+ summary = commands.summarize_items([self.item, i2], False)
+ self.assertEqual(summary, "2 items, F, 4kbps, 21:48, 1.9 KB")
+
+ i2.format = "G"
+ summary = commands.summarize_items([self.item, i2], False)
+ self.assertEqual(summary, "2 items, F 1, G 1, 4kbps, 21:48, 1.9 KB")
+
+ summary = commands.summarize_items([self.item, i2, i2], False)
+ self.assertEqual(summary, "3 items, G 2, F 1, 4kbps, 32:42, 2.9 KB")
class PathFormatTest(_common.TestCase):
@@ -1033,6 +1072,145 @@ class CompletionTest(_common.TestCase):
self.fail('test/test_completion.sh did not execute properly')
+class CommonOptionsParserCliTest(unittest.TestCase, TestHelper):
+ """Test CommonOptionsParser and formatting LibModel formatting on 'list'
+ command.
+ """
+ def setUp(self):
+ self.setup_beets()
+ self.lib = library.Library(':memory:')
+ self.item = _common.item()
+ self.item.path = 'xxx/yyy'
+ self.lib.add(self.item)
+ self.lib.add_album([self.item])
+
+ def tearDown(self):
+ self.teardown_beets()
+
+ def test_base(self):
+ l = self.run_with_output('ls')
+ self.assertEqual(l, 'the artist - the album - the title\n')
+
+ l = self.run_with_output('ls', '-a')
+ self.assertEqual(l, 'the album artist - the album\n')
+
+ def test_path_option(self):
+ l = self.run_with_output('ls', '-p')
+ self.assertEqual(l, 'xxx/yyy\n')
+
+ l = self.run_with_output('ls', '-a', '-p')
+ self.assertEqual(l, 'xxx\n')
+
+ def test_format_option(self):
+ l = self.run_with_output('ls', '-f', '$artist')
+ self.assertEqual(l, 'the artist\n')
+
+ l = self.run_with_output('ls', '-a', '-f', '$albumartist')
+ self.assertEqual(l, 'the album artist\n')
+
+ def test_root_format_option(self):
+ l = self.run_with_output('--format-item', '$artist',
+ '--format-album', 'foo', 'ls')
+ self.assertEqual(l, 'the artist\n')
+
+ l = self.run_with_output('--format-item', 'foo',
+ '--format-album', '$albumartist', 'ls', '-a')
+ self.assertEqual(l, 'the album artist\n')
+
+
+class CommonOptionsParserTest(unittest.TestCase, TestHelper):
+ def setUp(self):
+ self.setup_beets()
+
+ def tearDown(self):
+ self.teardown_beets()
+
+ def test_album_option(self):
+ parser = ui.CommonOptionsParser()
+ self.assertFalse(parser._album_flags)
+ parser.add_album_option()
+ self.assertTrue(bool(parser._album_flags))
+
+ self.assertEqual(parser.parse_args([]), ({'album': None}, []))
+ self.assertEqual(parser.parse_args(['-a']), ({'album': True}, []))
+ self.assertEqual(parser.parse_args(['--album']), ({'album': True}, []))
+
+ def test_path_option(self):
+ parser = ui.CommonOptionsParser()
+ parser.add_path_option()
+ self.assertFalse(parser._album_flags)
+
+ config['format_item'].set('$foo')
+ self.assertEqual(parser.parse_args([]), ({'path': None}, []))
+ self.assertEqual(config['format_item'].get(unicode), u'$foo')
+
+ self.assertEqual(parser.parse_args(['-p']),
+ ({'path': True, 'format': '$path'}, []))
+ self.assertEqual(parser.parse_args(['--path']),
+ ({'path': True, 'format': '$path'}, []))
+
+ self.assertEqual(config['format_item'].get(unicode), '$path')
+ self.assertEqual(config['format_album'].get(unicode), '$path')
+
+ def test_format_option(self):
+ parser = ui.CommonOptionsParser()
+ parser.add_format_option()
+ self.assertFalse(parser._album_flags)
+
+ config['format_item'].set('$foo')
+ self.assertEqual(parser.parse_args([]), ({'format': None}, []))
+ self.assertEqual(config['format_item'].get(unicode), u'$foo')
+
+ self.assertEqual(parser.parse_args(['-f', '$bar']),
+ ({'format': '$bar'}, []))
+ self.assertEqual(parser.parse_args(['--format', '$baz']),
+ ({'format': '$baz'}, []))
+
+ self.assertEqual(config['format_item'].get(unicode), '$baz')
+ self.assertEqual(config['format_album'].get(unicode), '$baz')
+
+ def test_format_option_with_target(self):
+ with self.assertRaises(KeyError):
+ ui.CommonOptionsParser().add_format_option(target='thingy')
+
+ parser = ui.CommonOptionsParser()
+ parser.add_format_option(target='item')
+
+ config['format_item'].set('$item')
+ config['format_album'].set('$album')
+
+ self.assertEqual(parser.parse_args(['-f', '$bar']),
+ ({'format': '$bar'}, []))
+
+ self.assertEqual(config['format_item'].get(unicode), '$bar')
+ self.assertEqual(config['format_album'].get(unicode), '$album')
+
+ def test_format_option_with_album(self):
+ parser = ui.CommonOptionsParser()
+ parser.add_album_option()
+ parser.add_format_option()
+
+ config['format_item'].set('$item')
+ config['format_album'].set('$album')
+
+ parser.parse_args(['-f', '$bar'])
+ self.assertEqual(config['format_item'].get(unicode), '$bar')
+ self.assertEqual(config['format_album'].get(unicode), '$album')
+
+ parser.parse_args(['-a', '-f', '$foo'])
+ self.assertEqual(config['format_item'].get(unicode), '$bar')
+ self.assertEqual(config['format_album'].get(unicode), '$foo')
+
+ parser.parse_args(['-f', '$foo2', '-a'])
+ self.assertEqual(config['format_album'].get(unicode), '$foo2')
+
+ def test_add_all_common_options(self):
+ parser = ui.CommonOptionsParser()
+ parser.add_all_common_options()
+ self.assertEqual(parser.parse_args([]),
+ ({'album': None, 'path': None, 'format': None}, []))
+
+
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
diff --git a/test/test_util.py b/test/test_util.py
new file mode 100644
index 000000000..3de8bfffc
--- /dev/null
+++ b/test/test_util.py
@@ -0,0 +1,184 @@
+# This file is part of beets.
+# Copyright 2015, Adrian Sampson.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+"""Tests for base utils from the beets.util package.
+"""
+from __future__ import (division, absolute_import, print_function,
+ unicode_literals)
+
+import sys
+import re
+import os
+import subprocess
+
+from mock import patch, Mock
+
+from test._common import unittest
+from test import _common
+from beets import util
+
+
+class UtilTest(unittest.TestCase):
+ def test_open_anything(self):
+ with _common.system_mock('Windows'):
+ self.assertEqual(util.open_anything(), 'start')
+
+ with _common.system_mock('Darwin'):
+ self.assertEqual(util.open_anything(), 'open')
+
+ with _common.system_mock('Tagada'):
+ self.assertEqual(util.open_anything(), 'xdg-open')
+
+ @patch('os.execlp')
+ @patch('beets.util.open_anything')
+ def test_interactive_open(self, mock_open, mock_execlp):
+ mock_open.return_value = 'tagada'
+ util.interactive_open('foo')
+ mock_execlp.assert_called_once_with('tagada', 'tagada', 'foo')
+ mock_execlp.reset_mock()
+
+ util.interactive_open('foo', 'bar')
+ mock_execlp.assert_called_once_with('bar', 'bar', 'foo')
+
+ def test_sanitize_unix_replaces_leading_dot(self):
+ with _common.platform_posix():
+ p = util.sanitize_path(u'one/.two/three')
+ self.assertFalse('.' in p)
+
+ def test_sanitize_windows_replaces_trailing_dot(self):
+ with _common.platform_windows():
+ p = util.sanitize_path(u'one/two./three')
+ self.assertFalse('.' in p)
+
+ def test_sanitize_windows_replaces_illegal_chars(self):
+ with _common.platform_windows():
+ p = util.sanitize_path(u':*?"<>|')
+ self.assertFalse(':' in p)
+ self.assertFalse('*' in p)
+ self.assertFalse('?' in p)
+ self.assertFalse('"' in p)
+ self.assertFalse('<' in p)
+ self.assertFalse('>' in p)
+ self.assertFalse('|' in p)
+
+ def test_sanitize_windows_replaces_trailing_space(self):
+ with _common.platform_windows():
+ p = util.sanitize_path(u'one/two /three')
+ self.assertFalse(' ' in p)
+
+ def test_sanitize_path_works_on_empty_string(self):
+ with _common.platform_posix():
+ p = util.sanitize_path(u'')
+ self.assertEqual(p, u'')
+
+ def test_sanitize_with_custom_replace_overrides_built_in_sub(self):
+ with _common.platform_posix():
+ p = util.sanitize_path(u'a/.?/b', [
+ (re.compile(r'foo'), u'bar'),
+ ])
+ self.assertEqual(p, u'a/.?/b')
+
+ def test_sanitize_with_custom_replace_adds_replacements(self):
+ with _common.platform_posix():
+ p = util.sanitize_path(u'foo/bar', [
+ (re.compile(r'foo'), u'bar'),
+ ])
+ self.assertEqual(p, u'bar/bar')
+
+ @unittest.skip('unimplemented: #359')
+ def test_sanitize_empty_component(self):
+ with _common.platform_posix():
+ p = util.sanitize_path(u'foo//bar', [
+ (re.compile(r'^$'), u'_'),
+ ])
+ self.assertEqual(p, u'foo/_/bar')
+
+ @patch('beets.util.subprocess.Popen')
+ def test_command_output(self, mock_popen):
+ def popen_fail(*args, **kwargs):
+ m = Mock(returncode=1)
+ m.communicate.return_value = None, None
+ return m
+
+ mock_popen.side_effect = popen_fail
+ with self.assertRaises(subprocess.CalledProcessError) as exc_context:
+ util.command_output([b"taga", b"\xc3\xa9"])
+ self.assertEquals(exc_context.exception.returncode, 1)
+ self.assertEquals(exc_context.exception.cmd, b"taga \xc3\xa9")
+
+
+class PathConversionTest(_common.TestCase):
+ def test_syspath_windows_format(self):
+ with _common.platform_windows():
+ path = os.path.join('a', 'b', 'c')
+ outpath = util.syspath(path)
+ self.assertTrue(isinstance(outpath, unicode))
+ self.assertTrue(outpath.startswith(u'\\\\?\\'))
+
+ def test_syspath_windows_format_unc_path(self):
+ # The \\?\ prefix on Windows behaves differently with UNC
+ # (network share) paths.
+ path = '\\\\server\\share\\file.mp3'
+ with _common.platform_windows():
+ outpath = util.syspath(path)
+ self.assertTrue(isinstance(outpath, unicode))
+ self.assertEqual(outpath, u'\\\\?\\UNC\\server\\share\\file.mp3')
+
+ def test_syspath_posix_unchanged(self):
+ with _common.platform_posix():
+ path = os.path.join('a', 'b', 'c')
+ outpath = util.syspath(path)
+ self.assertEqual(path, outpath)
+
+ def _windows_bytestring_path(self, path):
+ old_gfse = sys.getfilesystemencoding
+ sys.getfilesystemencoding = lambda: 'mbcs'
+ try:
+ with _common.platform_windows():
+ return util.bytestring_path(path)
+ finally:
+ sys.getfilesystemencoding = old_gfse
+
+ def test_bytestring_path_windows_encodes_utf8(self):
+ path = u'caf\xe9'
+ outpath = self._windows_bytestring_path(path)
+ self.assertEqual(path, outpath.decode('utf8'))
+
+ def test_bytesting_path_windows_removes_magic_prefix(self):
+ path = u'\\\\?\\C:\\caf\xe9'
+ outpath = self._windows_bytestring_path(path)
+ self.assertEqual(outpath, u'C:\\caf\xe9'.encode('utf8'))
+
+
+class PathTruncationTest(_common.TestCase):
+ def test_truncate_bytestring(self):
+ with _common.platform_posix():
+ p = util.truncate_path(b'abcde/fgh', 4)
+ self.assertEqual(p, b'abcd/fgh')
+
+ def test_truncate_unicode(self):
+ with _common.platform_posix():
+ p = util.truncate_path(u'abcde/fgh', 4)
+ self.assertEqual(p, u'abcd/fgh')
+
+ def test_truncate_preserves_extension(self):
+ with _common.platform_posix():
+ p = util.truncate_path(u'abcde/fgh.ext', 5)
+ self.assertEqual(p, u'abcde/f.ext')
+
+
+def suite():
+ return unittest.TestLoader().loadTestsFromName(__name__)
+
+if __name__ == b'__main__':
+ unittest.main(defaultTest='suite')