mirror of
https://github.com/beetbox/beets.git
synced 2026-02-26 09:11:32 +01:00
Merge branch 'master' into thumbnails
This commit is contained in:
commit
265fa962eb
74 changed files with 2121 additions and 671 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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``
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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+
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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={},
|
||||
|
|
|
|||
104
beets/library.py
104
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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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+).*"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
54
beetsplug/cue.py
Normal file
54
beetsplug/cue.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
|||
<http://audiotools.sourceforge.net/>`_ 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]
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
------------------------
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ extlinks = {
|
|||
}
|
||||
|
||||
# Options for HTML output
|
||||
html_theme = 'default'
|
||||
html_theme = 'classic'
|
||||
htmlhelp_basename = 'beetsdoc'
|
||||
|
||||
# Options for LaTeX output
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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</reference/pathformat>`. The usage is inspired by, and
|
||||
therefore similar to, the :ref:`list <list-cmd>` 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``.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ configuration file. The available options are:
|
|||
track. This uses the same template syntax as beets'
|
||||
:doc:`path formats </reference/pathformat>`. The usage is inspired by, and
|
||||
therefore similar to, the :ref:`list <list-cmd>` command.
|
||||
Default: :ref:`list_format_item`.
|
||||
Default: :ref:`format_item`.
|
||||
- **total**: Print a single count of missing tracks in all albums.
|
||||
Default: ``no``.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
-------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <config>`.
|
||||
|
||||
Beets also uses the ``BEETSDIR`` environment variable to look for
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
5
setup.py
5
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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:')
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
222
test/test_smartplaylist.py
Normal file
222
test/test_smartplaylist.py
Normal file
|
|
@ -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')
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
182
test/test_ui.py
182
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__)
|
||||
|
||||
|
|
|
|||
184
test/test_util.py
Normal file
184
test/test_util.py
Normal file
|
|
@ -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')
|
||||
Loading…
Reference in a new issue