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