mirror of
https://github.com/beetbox/beets.git
synced 2025-12-10 10:32:34 +01:00
merge lots of work on the stable branch
This commit is contained in:
commit
e17cd6beba
31 changed files with 618 additions and 288 deletions
|
|
@ -31,11 +31,11 @@ imagine for your music collection. Via `plugins`_, beets becomes a panacea:
|
|||
If beets doesn't do what you want yet, `writing your own plugin`_ is
|
||||
shockingly simple if you know a little Python.
|
||||
|
||||
.. _plugins: http://beets.readthedocs.org/en/latest/plugins/
|
||||
.. _plugins: http://beets.readthedocs.org/page/plugins/
|
||||
.. _MPD: http://mpd.wikia.com/
|
||||
.. _MusicBrainz music collection: http://musicbrainz.org/show/collection/
|
||||
.. _writing your own plugin:
|
||||
http://beets.readthedocs.org/en/latest/plugins/#writing-plugins
|
||||
http://beets.readthedocs.org/page/plugins/#writing-plugins
|
||||
.. _HTML5 Audio:
|
||||
http://www.w3.org/TR/html-markup/audio.html
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ cutting edge, type ``pip install beets==dev`` for the `latest source`_.) Check
|
|||
out the `Getting Started`_ guide to learn more about installing and using beets.
|
||||
|
||||
.. _its Web site: http://beets.radbox.org/
|
||||
.. _Getting Started: http://beets.readthedocs.org/en/latest/guides/main.html
|
||||
.. _Getting Started: http://beets.readthedocs.org/page/guides/main.html
|
||||
.. _@b33ts: http://twitter.com/b33ts/
|
||||
.. _latest source: https://github.com/sampsyo/beets/tarball/master#egg=beets-dev
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
__version__ = '1.0b16'
|
||||
__version__ = '1.0rc1'
|
||||
__author__ = 'Adrian Sampson <adrian@radbox.org>'
|
||||
|
||||
import beets.library
|
||||
|
|
|
|||
|
|
@ -167,14 +167,19 @@ def current_metadata(items):
|
|||
"""Returns the most likely artist and album for a set of Items.
|
||||
Each is determined by tag reflected by the plurality of the Items.
|
||||
"""
|
||||
keys = 'artist', 'album'
|
||||
likelies = {}
|
||||
consensus = {}
|
||||
for key in keys:
|
||||
for key in 'artist', 'album', 'albumartist':
|
||||
values = [getattr(item, key) for item in items if item]
|
||||
likelies[key], freq = plurality(values)
|
||||
consensus[key] = (freq == len(values))
|
||||
return likelies['artist'], likelies['album'], consensus['artist']
|
||||
|
||||
if consensus['albumartist'] and likelies['albumartist']:
|
||||
artist = likelies['albumartist']
|
||||
else:
|
||||
artist = likelies['artist']
|
||||
|
||||
return artist, likelies['album'], consensus['artist']
|
||||
|
||||
def assign_items(items, tracks):
|
||||
"""Given a list of Items and a list of TrackInfo objects, find the
|
||||
|
|
|
|||
|
|
@ -131,7 +131,12 @@ def _set_date_str(info, date_str):
|
|||
date_parts = date_str.split('-')
|
||||
for key in ('year', 'month', 'day'):
|
||||
if date_parts:
|
||||
setattr(info, key, int(date_parts.pop(0)))
|
||||
date_part = date_parts.pop(0)
|
||||
try:
|
||||
date_num = int(date_part)
|
||||
except ValueError:
|
||||
continue
|
||||
setattr(info, key, date_num)
|
||||
|
||||
def album_info(release):
|
||||
"""Takes a MusicBrainz release result dictionary and returns a beets
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from beets import config
|
|||
from beets.util import pipeline
|
||||
from beets.util import syspath, normpath, displayable_path
|
||||
from beets.util.enumeration import enum
|
||||
from beets.mediafile import UnreadableFileError
|
||||
|
||||
action = enum(
|
||||
'SKIP', 'ASIS', 'TRACKS', 'MANUAL', 'APPLY', 'MANUAL_ID',
|
||||
|
|
@ -551,7 +552,13 @@ def read_tasks(session):
|
|||
# Check whether the path is to a file.
|
||||
if config['import']['singletons'] and \
|
||||
not os.path.isdir(syspath(toppath)):
|
||||
item = library.Item.from_path(toppath)
|
||||
try:
|
||||
item = library.Item.from_path(toppath)
|
||||
except UnreadableFileError:
|
||||
log.warn(u'unreadable file: {0}'.format(
|
||||
util.displayable_path(toppath)
|
||||
))
|
||||
continue
|
||||
yield ImportTask.item_task(item)
|
||||
continue
|
||||
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ PF_KEY_DEFAULT = 'default'
|
|||
log = logging.getLogger('beets')
|
||||
if not log.handlers:
|
||||
log.addHandler(logging.StreamHandler())
|
||||
log.propagate = False # Don't propagate to root handler.
|
||||
|
||||
# A little SQL utility.
|
||||
def _orelse(exp1, exp2):
|
||||
|
|
@ -1001,7 +1002,7 @@ class Library(BaseLibrary):
|
|||
self.path = bytestring_path(normpath(path))
|
||||
self.directory = bytestring_path(normpath(directory))
|
||||
self.path_formats = path_formats
|
||||
self.art_filename = bytestring_path(art_filename)
|
||||
self.art_filename = art_filename
|
||||
self.replacements = replacements
|
||||
|
||||
self._memotable = {} # Used for template substitution performance.
|
||||
|
|
@ -1160,6 +1161,9 @@ class Library(BaseLibrary):
|
|||
extension = extension.decode('utf8', 'ignore')
|
||||
subpath += extension.lower()
|
||||
|
||||
# Truncate too-long components.
|
||||
subpath = util.truncate_path(subpath, pathmod)
|
||||
|
||||
if fragment:
|
||||
return subpath
|
||||
else:
|
||||
|
|
@ -1559,9 +1563,19 @@ class Album(BaseAlbum):
|
|||
"""
|
||||
image = bytestring_path(image)
|
||||
item_dir = item_dir or self.item_dir()
|
||||
|
||||
if not isinstance(self._library.art_filename,Template):
|
||||
self._library.art_filename = Template(self._library.art_filename)
|
||||
|
||||
subpath = util.sanitize_path(util.sanitize_for_path(
|
||||
self.evaluate_template(self._library.art_filename)
|
||||
))
|
||||
subpath = bytestring_path(subpath)
|
||||
|
||||
_, ext = os.path.splitext(image)
|
||||
dest = os.path.join(item_dir, self._library.art_filename + ext)
|
||||
return dest
|
||||
dest = os.path.join(item_dir, subpath + ext)
|
||||
|
||||
return bytestring_path(dest)
|
||||
|
||||
def set_art(self, path, copy=True):
|
||||
"""Sets the album's cover art to the image at the given path.
|
||||
|
|
|
|||
|
|
@ -573,10 +573,17 @@ class ImageField(object):
|
|||
# No cover found.
|
||||
return None
|
||||
|
||||
elif obj.type == 'flac':
|
||||
pictures = obj.mgfile.pictures
|
||||
if pictures:
|
||||
return pictures[0].data or None
|
||||
else:
|
||||
return None
|
||||
|
||||
else:
|
||||
# Here we're assuming everything but MP3 and MPEG-4 uses
|
||||
# the Xiph/Vorbis Comments standard. This may not be valid.
|
||||
# http://wiki.xiph.org/VorbisComment#Cover_art
|
||||
# Here we're assuming everything but MP3, MPEG-4, and FLAC
|
||||
# use the Xiph/Vorbis Comments standard. This may not be
|
||||
# valid. http://wiki.xiph.org/VorbisComment#Cover_art
|
||||
|
||||
if 'metadata_block_picture' not in obj.mgfile:
|
||||
# Try legacy COVERART tags.
|
||||
|
|
@ -624,6 +631,15 @@ class ImageField(object):
|
|||
cover = mutagen.mp4.MP4Cover(val, self._mp4kind(val))
|
||||
obj.mgfile['covr'] = [cover]
|
||||
|
||||
elif obj.type == 'flac':
|
||||
obj.mgfile.clear_pictures()
|
||||
|
||||
if val is not None:
|
||||
pic = mutagen.flac.Picture()
|
||||
pic.data = val
|
||||
pic.mime = self._mime(val)
|
||||
obj.mgfile.add_picture(pic)
|
||||
|
||||
else:
|
||||
# Again, assuming Vorbis Comments standard.
|
||||
|
||||
|
|
@ -691,8 +707,8 @@ class MediaFile(object):
|
|||
)
|
||||
try:
|
||||
self.mgfile = mutagen.File(path)
|
||||
except unreadable_exc:
|
||||
log.warn('header parsing failed')
|
||||
except unreadable_exc as exc:
|
||||
log.debug(u'header parsing failed: {0}'.format(unicode(exc)))
|
||||
raise UnreadableFileError('Mutagen could not read file')
|
||||
except IOError:
|
||||
raise UnreadableFileError('could not read file')
|
||||
|
|
|
|||
|
|
@ -645,7 +645,7 @@ def _raw_main(args, configfh):
|
|||
dbpath,
|
||||
config['directory'].as_filename(),
|
||||
get_path_formats(),
|
||||
config['art_filename'].get(unicode),
|
||||
Template(config['art_filename'].get(unicode)),
|
||||
config['timeout'].as_number(),
|
||||
get_replacements(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -693,7 +693,7 @@ def import_func(lib, opts, args):
|
|||
config['import']['move'] = False
|
||||
|
||||
if opts.library:
|
||||
query = args
|
||||
query = decargs(args)
|
||||
paths = []
|
||||
else:
|
||||
query = None
|
||||
|
|
|
|||
|
|
@ -59,12 +59,14 @@ class HumanReadableException(Exception):
|
|||
|
||||
def _reasonstr(self):
|
||||
"""Get the reason as a string."""
|
||||
if isinstance(self.reason, basestring):
|
||||
if isinstance(self.reason, unicode):
|
||||
return self.reason
|
||||
elif isinstance(self.reason, basestring): # Byte string.
|
||||
return self.reason.decode('utf8', 'ignore')
|
||||
elif hasattr(self.reason, 'strerror'): # i.e., EnvironmentError
|
||||
return self.reason.strerror
|
||||
else:
|
||||
return u'"{0}"'.format(self.reason)
|
||||
return u'"{0}"'.format(unicode(self.reason))
|
||||
|
||||
def get_message(self):
|
||||
"""Create the human-readable description of the error, sans
|
||||
|
|
@ -95,7 +97,7 @@ class FilesystemError(HumanReadableException):
|
|||
clause = 'while {0} {1} to {2}'.format(
|
||||
self._gerund(), repr(self.paths[0]), repr(self.paths[1])
|
||||
)
|
||||
elif self.verb in ('delete', 'write'):
|
||||
elif self.verb in ('delete', 'write', 'create'):
|
||||
clause = 'while {0} {1}'.format(
|
||||
self._gerund(), repr(self.paths[0])
|
||||
)
|
||||
|
|
@ -185,7 +187,11 @@ def mkdirall(path):
|
|||
"""
|
||||
for ancestor in ancestry(path):
|
||||
if not os.path.isdir(syspath(ancestor)):
|
||||
os.mkdir(syspath(ancestor))
|
||||
try:
|
||||
os.mkdir(syspath(ancestor))
|
||||
except (OSError, IOError) as exc:
|
||||
raise FilesystemError(exc, 'create', (ancestor,),
|
||||
traceback.format_exc())
|
||||
|
||||
def prune_dirs(path, root=None, clutter=('.DS_Store', 'Thumbs.db')):
|
||||
"""If path is an empty directory, then remove it. Recursively remove
|
||||
|
|
@ -448,21 +454,35 @@ def sanitize_path(path, pathmod=None, replacements=None):
|
|||
if not comps:
|
||||
return ''
|
||||
for i, comp in enumerate(comps):
|
||||
# Replace special characters.
|
||||
for regex, repl in replacements:
|
||||
comp = regex.sub(repl, comp)
|
||||
|
||||
# Truncate each component.
|
||||
comp = comp[:MAX_FILENAME_LENGTH]
|
||||
|
||||
comps[i] = comp
|
||||
return pathmod.join(*comps)
|
||||
|
||||
def sanitize_for_path(value, pathmod, key=None):
|
||||
def truncate_path(path, pathmod=None, length=MAX_FILENAME_LENGTH):
|
||||
"""Given a bytestring path or a Unicode path fragment, truncate the
|
||||
components to a legal length. In the last component, the extension
|
||||
is preserved.
|
||||
"""
|
||||
pathmod = pathmod or os.path
|
||||
comps = components(path, pathmod)
|
||||
|
||||
out = [c[:length] for c in comps]
|
||||
base, ext = pathmod.splitext(comps[-1])
|
||||
if ext:
|
||||
# Last component has an extension.
|
||||
base = base[:length - len(ext)]
|
||||
out[-1] = base + ext
|
||||
|
||||
return pathmod.join(*out)
|
||||
|
||||
def sanitize_for_path(value, pathmod=None, key=None):
|
||||
"""Sanitize the value for inclusion in a path: replace separators
|
||||
with _, etc. Doesn't guarantee that the whole path will be valid;
|
||||
you should still call sanitize_path on the complete path.
|
||||
"""
|
||||
pathmod = pathmod or os.path
|
||||
|
||||
if isinstance(value, basestring):
|
||||
for sep in (pathmod.sep, pathmod.altsep):
|
||||
if sep:
|
||||
|
|
@ -482,6 +502,7 @@ def sanitize_for_path(value, pathmod, key=None):
|
|||
value = u'%ikHz' % ((value or 0) // 1000)
|
||||
else:
|
||||
value = unicode(value)
|
||||
|
||||
return value
|
||||
|
||||
def str2bool(value):
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ def acoustid_match(path):
|
|||
_matches, _fingerprints, and _acoustids dictionaries accordingly.
|
||||
"""
|
||||
try:
|
||||
duration, fp = acoustid.fingerprint_file(path)
|
||||
duration, fp = acoustid.fingerprint_file(util.syspath(path))
|
||||
except acoustid.FingerprintGenerationError as exc:
|
||||
log.error('fingerprinting of %s failed: %s' %
|
||||
(repr(path), str(exc)))
|
||||
|
|
|
|||
|
|
@ -32,41 +32,22 @@ _fs_lock = threading.Lock()
|
|||
|
||||
def encode(source, dest):
|
||||
log.info(u'Started encoding {0}'.format(util.displayable_path(source)))
|
||||
temp_dest = dest + '~'
|
||||
|
||||
source_ext = os.path.splitext(source)[1].lower()
|
||||
if source_ext == '.flac':
|
||||
decode = Popen([conf['flac'], '-c', '-d', '-s', source],
|
||||
stdout=PIPE)
|
||||
encode = Popen([conf['lame']] + conf['opts'] + ['-', temp_dest],
|
||||
stdin=decode.stdout, stderr=DEVNULL)
|
||||
decode.stdout.close()
|
||||
encode.communicate()
|
||||
elif source_ext == '.mp3':
|
||||
encode = Popen([conf['lame']] + conf['opts'] + ['--mp3input'] +
|
||||
[source, temp_dest], close_fds=True, stderr=DEVNULL)
|
||||
encode.communicate()
|
||||
else:
|
||||
log.error(u'Only converting from FLAC or MP3 implemented')
|
||||
return
|
||||
encode = Popen([conf['ffmpeg']] + ['-i', source] + conf['opts'] +
|
||||
[dest], close_fds=True, stderr=DEVNULL)
|
||||
encode.wait()
|
||||
if encode.returncode != 0:
|
||||
# Something went wrong (probably Ctrl+C), remove temporary files
|
||||
log.info(u'Encoding {0} failed. Cleaning up...'.format(source))
|
||||
util.remove(temp_dest)
|
||||
util.prune_dirs(os.path.dirname(temp_dest))
|
||||
util.remove(dest)
|
||||
util.prune_dirs(os.path.dirname(dest))
|
||||
return
|
||||
shutil.move(temp_dest, dest)
|
||||
log.info(u'Finished encoding {0}'.format(util.displayable_path(source)))
|
||||
|
||||
|
||||
def convert_item(lib, dest_dir):
|
||||
while True:
|
||||
item = yield
|
||||
if item.format != 'FLAC' and item.format != 'MP3':
|
||||
log.info(u'Skipping {0} (unsupported format)'.format(
|
||||
util.displayable_path(item.path)
|
||||
))
|
||||
continue
|
||||
|
||||
dest = os.path.join(dest_dir, lib.destination(item, fragment=True))
|
||||
dest = os.path.splitext(dest)[0] + '.mp3'
|
||||
|
|
@ -122,10 +103,9 @@ class ConvertPlugin(BeetsPlugin):
|
|||
conf['dest'] = ui.config_val(config, 'convert', 'dest', None)
|
||||
conf['threads'] = int(ui.config_val(config, 'convert', 'threads',
|
||||
util.cpu_count()))
|
||||
conf['flac'] = ui.config_val(config, 'convert', 'flac', 'flac')
|
||||
conf['lame'] = ui.config_val(config, 'convert', 'lame', 'lame')
|
||||
conf['ffmpeg'] = ui.config_val(config, 'convert', 'ffmpeg', 'ffmpeg')
|
||||
conf['opts'] = ui.config_val(config, 'convert',
|
||||
'opts', '-V2').split(' ')
|
||||
'opts', '-aq 2').split(' ')
|
||||
conf['max_bitrate'] = int(ui.config_val(config, 'convert',
|
||||
'max_bitrate', '500'))
|
||||
conf['embed'] = ui.config_val(config, 'convert', 'embed', True,
|
||||
|
|
|
|||
107
beetsplug/echonest_tempo.py
Normal file
107
beetsplug/echonest_tempo.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2012, David Brenner <david.a.brenner gmail>
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Gets tempo (bpm) for imported music from the EchoNest API. Requires
|
||||
the pyechonest library (https://github.com/echonest/pyechonest).
|
||||
"""
|
||||
import logging
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets import ui
|
||||
from beets.ui import commands
|
||||
import pyechonest.config
|
||||
import pyechonest.song
|
||||
|
||||
# Global logger.
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
# The official Echo Nest API key for beets. This can be overridden by
|
||||
# the user.
|
||||
ECHONEST_APIKEY = 'NY2KTZHQ0QDSHBAP6'
|
||||
|
||||
def fetch_item_tempo(lib, loglevel, item, write):
|
||||
"""Fetch and store tempo for a single item. If ``write``, then the
|
||||
tempo will also be written to the file itself in the bpm field. The
|
||||
``loglevel`` parameter controls the visibility of the function's
|
||||
status log messages.
|
||||
"""
|
||||
# Skip if the item already has the tempo field.
|
||||
if item.bpm:
|
||||
log.log(loglevel, u'bpm already present: %s - %s' %
|
||||
(item.artist, item.title))
|
||||
return
|
||||
|
||||
# Fetch tempo.
|
||||
tempo = get_tempo(item.artist, item.title)
|
||||
if not tempo:
|
||||
log.log(loglevel, u'tempo not found: %s - %s' %
|
||||
(item.artist, item.title))
|
||||
return
|
||||
|
||||
log.log(loglevel, u'fetched tempo: %s - %s' %
|
||||
(item.artist, item.title))
|
||||
item.bpm = tempo
|
||||
if write:
|
||||
item.write()
|
||||
lib.store(item)
|
||||
|
||||
def get_tempo(artist, title):
|
||||
"""Get the tempo for a song."""
|
||||
# Unfortunately, all we can do is search by artist and title. EchoNest
|
||||
# supports foreign ids from MusicBrainz, but currently only for artists,
|
||||
# not individual tracks/recordings.
|
||||
results = pyechonest.song.search(
|
||||
artist=artist, title=title, results=1, buckets=['audio_summary']
|
||||
)
|
||||
if len(results) > 0:
|
||||
return results[0].audio_summary['tempo']
|
||||
else:
|
||||
return None
|
||||
|
||||
AUTOFETCH = True
|
||||
class EchoNestTempoPlugin(BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(EchoNestTempoPlugin, self).__init__()
|
||||
self.import_stages = [self.imported]
|
||||
|
||||
def commands(self):
|
||||
cmd = ui.Subcommand('tempo', help='fetch song tempo (bpm)')
|
||||
cmd.parser.add_option('-p', '--print', dest='printbpm',
|
||||
action='store_true', default=False,
|
||||
help='print tempo (bpm) to console')
|
||||
def func(lib, config, opts, args):
|
||||
# The "write to files" option corresponds to the
|
||||
# import_write config value.
|
||||
write = ui.config_val(config, 'beets', 'import_write',
|
||||
commands.DEFAULT_IMPORT_WRITE, bool)
|
||||
|
||||
for item in lib.items(ui.decargs(args)):
|
||||
fetch_item_tempo(lib, logging.INFO, item, write)
|
||||
if opts.printbpm and item.bpm:
|
||||
ui.print_('{0} BPM'.format(item.bpm))
|
||||
cmd.func = func
|
||||
return [cmd]
|
||||
|
||||
def configure(self, config):
|
||||
global AUTOFETCH
|
||||
AUTOFETCH = ui.config_val(config, 'echonest_tempo', 'autofetch', True,
|
||||
bool)
|
||||
apikey = ui.config_val(config, 'echonest_tempo', 'apikey',
|
||||
ECHONEST_APIKEY)
|
||||
pyechonest.config.ECHO_NEST_API_KEY = apikey
|
||||
|
||||
# Auto-fetch tempo on import.
|
||||
def imported(self, config, task):
|
||||
if AUTOFETCH:
|
||||
for item in task.imported_items():
|
||||
fetch_item_tempo(config.lib, logging.DEBUG, item, False)
|
||||
|
|
@ -47,7 +47,7 @@ def _fetch_image(url):
|
|||
try:
|
||||
_, headers = urllib.urlretrieve(url, filename=fn)
|
||||
except IOError:
|
||||
log.debug('error fetching art')
|
||||
log.debug(u'error fetching art')
|
||||
return
|
||||
|
||||
# Make sure it's actually an image.
|
||||
|
|
@ -105,7 +105,7 @@ def aao_art(asin):
|
|||
image_url = m.group(1)
|
||||
return image_url
|
||||
else:
|
||||
log.debug('fetchart: no image found on page')
|
||||
log.debug(u'fetchart: no image found on page')
|
||||
|
||||
|
||||
# Art from the filesystem.
|
||||
|
|
@ -126,14 +126,14 @@ def art_in_path(path):
|
|||
for fn in images:
|
||||
for name in COVER_NAMES:
|
||||
if fn.lower().startswith(name):
|
||||
log.debug('fetchart: using well-named art file {0}'.format(
|
||||
log.debug(u'fetchart: using well-named art file {0}'.format(
|
||||
util.displayable_path(fn)
|
||||
))
|
||||
return os.path.join(path, fn)
|
||||
|
||||
# Fall back to any image in the folder.
|
||||
if images:
|
||||
log.debug('fetchart: using fallback art file {0}'.format(
|
||||
log.debug(u'fetchart: using fallback art file {0}'.format(
|
||||
util.displayable_path(images[0])
|
||||
))
|
||||
return os.path.join(path, images[0])
|
||||
|
|
|
|||
|
|
@ -51,11 +51,11 @@ def unescape(text):
|
|||
"""Resolves &#xxx; HTML entities (and some others)."""
|
||||
if isinstance(text, str):
|
||||
text = text.decode('utf8', 'ignore')
|
||||
out = text.replace(' ', ' ')
|
||||
out = text.replace(u' ', u' ')
|
||||
def replchar(m):
|
||||
num = m.group(1)
|
||||
return unichr(int(num))
|
||||
out = re.sub("&#(\d+);", replchar, out)
|
||||
out = re.sub(u"&#(\d+);", replchar, out)
|
||||
return out
|
||||
|
||||
def extract_text(html, starttag):
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from beets.ui import commands
|
|||
log = logging.getLogger('beets')
|
||||
|
||||
DEFAULT_REFERENCE_LOUDNESS = 89
|
||||
SAMPLE_MAX = 1 << 15
|
||||
|
||||
class ReplayGainError(Exception):
|
||||
"""Raised when an error occurs during mp3gain/aacgain execution.
|
||||
|
|
@ -54,7 +55,7 @@ def parse_tool_output(text):
|
|||
'file': parts[0],
|
||||
'mp3gain': int(parts[1]),
|
||||
'gain': float(parts[2]),
|
||||
'peak': float(parts[3]),
|
||||
'peak': float(parts[3]) / SAMPLE_MAX,
|
||||
'maxgain': int(parts[4]),
|
||||
'mingain': int(parts[5]),
|
||||
})
|
||||
|
|
@ -161,6 +162,10 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
|
||||
def requires_gain(self, item, album=False):
|
||||
"""Does the gain need to be computed?"""
|
||||
if 'mp3gain' in self.command and item.format != 'MP3':
|
||||
return False
|
||||
elif 'aacgain' in self.command and item.format not in ('MP3', 'AAC'):
|
||||
return False
|
||||
return self.overwrite or \
|
||||
(not item.rg_track_gain or not item.rg_track_peak) or \
|
||||
((not item.rg_album_gain or not item.rg_album_peak) and \
|
||||
|
|
@ -189,7 +194,7 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
# Adjust to avoid clipping.
|
||||
cmd = cmd + ['-k']
|
||||
else:
|
||||
# Disable clipping warning.
|
||||
# Disable clipping warning.
|
||||
cmd = cmd + ['-c']
|
||||
if self.apply_gain:
|
||||
# Lossless audio adjustment.
|
||||
|
|
@ -198,13 +203,17 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
cmd = cmd + [syspath(i.path) for i in items]
|
||||
|
||||
log.debug(u'replaygain: analyzing {0} files'.format(len(items)))
|
||||
output = call(cmd)
|
||||
try:
|
||||
output = call(cmd)
|
||||
except ReplayGainError as exc:
|
||||
log.warn(u'replaygain: analysis failed ({0})'.format(exc))
|
||||
return
|
||||
log.debug(u'replaygain: analysis finished')
|
||||
results = parse_tool_output(output)
|
||||
|
||||
return results
|
||||
|
||||
def store_gain(self, lib, items, rgain_infos, album=None):
|
||||
def store_gain(self, lib, items, rgain_infos, album=None):
|
||||
"""Store computed ReplayGain values to the Items and the Album
|
||||
(if it is provided).
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
Changelog
|
||||
=========
|
||||
|
||||
1.0b16 (in development)
|
||||
1.0rc1 (in development)
|
||||
-----------------------
|
||||
|
||||
* New plugin: :doc:`/plugins/convert` transcodes music and embeds album art
|
||||
|
|
@ -10,6 +10,8 @@ Changelog
|
|||
* New plugin: :doc:`/plugins/fuzzy_search` lets you find albums and tracks using
|
||||
fuzzy string matching so you don't have to type (or even remember) their exact
|
||||
names. Thanks to Philippe Mongeau.
|
||||
* New plugin: :doc:`/plugins/echonest_tempo` fetches tempo (BPM) information
|
||||
from `The Echo Nest`_. Thanks to David Brenner.
|
||||
* New plugin: :doc:`/plugins/the` adds a template function that helps format
|
||||
text for nicely-sorted directory listings. Thanks to Blemjhoo Tezoulbr.
|
||||
* New plugin: :doc:`/plugins/zero` filters out undesirable fields before they
|
||||
|
|
@ -21,8 +23,8 @@ Changelog
|
|||
the `mp3gain`_ or `aacgain`_ command-line tools instead of the failure-prone
|
||||
Gstreamer ReplayGain implementation. Thanks to Fabrice Laporte.
|
||||
* :doc:`/plugins/fetchart` and :doc:`/plugins/embedart`: Both plugins can now
|
||||
resize album art to avoid excessively large images. Thanks to
|
||||
Fabrice Laporte.
|
||||
resize album art to avoid excessively large images. Use the ``maxwidth``
|
||||
config option with either plugin. Thanks to Fabrice Laporte.
|
||||
* :doc:`/plugins/scrub`: Scrubbing now removes *all* types of tags from a file
|
||||
rather than just one. For example, if your FLAC file has both ordinary FLAC
|
||||
tags and ID3 tags, the ID3 tags are now also removed.
|
||||
|
|
@ -33,10 +35,14 @@ Changelog
|
|||
* The output of the :ref:`update-cmd`, :ref:`remove-cmd`, and :ref:`modify-cmd`
|
||||
commands now respects the :ref:`list_format_album` and
|
||||
:ref:`list_format_item` config options. Thanks to Mike Kazantsev.
|
||||
* The :ref:`art-filename` option can now be a template rather than a simple
|
||||
string. Thanks to Jarrod Beardwood.
|
||||
* Fix album queries for ``artpath`` and other non-item fields.
|
||||
* Null values in the database can now be matched with the empty-string regular
|
||||
expression, ``^$``.
|
||||
* Queries now correctly match non-string values in path format predicates.
|
||||
* When autotagging a various-artists album, the album artist field is now
|
||||
used instead of the majority track artist.
|
||||
* :doc:`/plugins/lastgenre`: Use the albums' existing genre tags if they pass
|
||||
the whitelist (thanks to Fabrice Laporte).
|
||||
* :doc:`/plugins/lastgenre`: Add a ``lastgenre`` command for fetching genres
|
||||
|
|
@ -55,9 +61,16 @@ Changelog
|
|||
* :doc:`/plugins/mbcollection`: Fix an error when submitting a large number of
|
||||
releases (we now submit only 200 releases at a time instead of 350). Thanks
|
||||
to Jonathan Towne.
|
||||
* :doc:`/plugins/embedart`: Made the method for embedding art into FLAC files
|
||||
`standard
|
||||
<https://wiki.xiph.org/VorbisComment#METADATA_BLOCK_PICTURE>`_-compliant.
|
||||
Thanks to Daniele Sluijters.
|
||||
* Add the track mapping dictionary to the ``album_distance`` plugin function.
|
||||
* When an exception is raised while reading a file, the path of the file in
|
||||
question is now logged (thanks to Mike Kazantsev).
|
||||
* Truncate long filenames based on their *bytes* rather than their Unicode
|
||||
*characters*, fixing situations where encoded names could be too long.
|
||||
* Filename truncation now incorporates the length of the extension.
|
||||
* Fix an assertion failure when the MusicBrainz main database and search server
|
||||
disagree.
|
||||
* Fix a bug that caused the :doc:`/plugins/lastgenre` and other plugins not to
|
||||
|
|
@ -69,10 +82,15 @@ Changelog
|
|||
* Fix a crash when input is read from a pipe without a specified encoding.
|
||||
* Fix some problem with identifying files on Windows with Unicode directory
|
||||
names in their path.
|
||||
* Add a human-readable error message when writing files' tags fails.
|
||||
* Fix a crash when Unicode queries were used with ``import -L`` re-imports.
|
||||
* Fix an error when fingerprinting files with Unicode filenames on Windows.
|
||||
* Warn instead of crashing when importing a specific file in singleton mode.
|
||||
* Add human-readable error messages when writing files' tags fails or when a
|
||||
directory can't be created.
|
||||
* Changed plugin loading so that modules can be imported without
|
||||
unintentionally loading the plugins they contain.
|
||||
|
||||
.. _The Echo Nest: http://the.echonest.com/
|
||||
.. _Tomahawk resolver: http://beets.radbox.org/blog/tomahawk-resolver.html
|
||||
.. _mp3gain: http://mp3gain.sourceforge.net/download.php
|
||||
.. _aacgain: http://aacgain.altosdesign.com
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ master_doc = 'index'
|
|||
project = u'beets'
|
||||
copyright = u'2012, Adrian Sampson'
|
||||
|
||||
version = '1.0b16'
|
||||
release = '1.0b16'
|
||||
version = '1.0rc1'
|
||||
release = '1.0rc1'
|
||||
|
||||
pygments_style = 'sphinx'
|
||||
|
||||
|
|
|
|||
|
|
@ -2,22 +2,23 @@ Convert Plugin
|
|||
==============
|
||||
|
||||
The ``convert`` plugin lets you convert parts of your collection to a directory
|
||||
of your choice. Currently only converting from MP3 or FLAC to MP3 is supported.
|
||||
of your choice. It converts all input formats supported by `FFmpeg`_ to MP3.
|
||||
It will skip files that are already present in the target directory. Converted
|
||||
files follow the same path formats as your library.
|
||||
|
||||
.. _FFmpeg: http://ffmpeg.org
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
First, enable the ``convert`` plugin (see :doc:`/plugins/index`).
|
||||
|
||||
To transcode music, this plugin requires the ``flac`` and ``lame`` command-line
|
||||
tools. If those executables are in your path, they will be found automatically
|
||||
by the plugin. Otherwise, configure the plugin to locate the executables::
|
||||
To transcode music, this plugin requires the ``ffmpeg`` command-line
|
||||
tool. If its executable is in your path, it will be found automatically
|
||||
by the plugin. Otherwise, configure the plugin to locate the executable::
|
||||
|
||||
[convert]
|
||||
flac: /usr/bin/flac
|
||||
lame: /usr/bin/lame
|
||||
ffmpeg: /usr/bin/ffmpeg
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
|
@ -44,10 +45,12 @@ The plugin offers several configuration options, all of which live under the
|
|||
* If you set ``max_bitrate``, all MP3 files with a higher bitrate will be
|
||||
transcoded and those with a lower bitrate will simply be copied. Note that
|
||||
this does not guarantee that all converted files will have a lower
|
||||
bitrate---that depends on the encoder and its configuration. By default, FLAC
|
||||
files will be converted and all MP3s will be copied without transcoding.
|
||||
* ``opts`` are the encoding options that are passed to ``lame``. Default:
|
||||
"-V2". Please refer to the LAME documentation for possible options.
|
||||
bitrate---that depends on the encoder and its configuration. By default MP3s
|
||||
will be copied without transcoding and all other formats will be converted.
|
||||
* ``opts`` are the encoding options that are passed to ``ffmpeg``. Default:
|
||||
"-aq 2". (Note that "-aq <num>" is equivalent to the LAME option "-V
|
||||
<num>".) If you want to specify a bitrate, use "-ab <bitrate>". Refer to the
|
||||
`FFmpeg`_ documentation for more details.
|
||||
* Finally, ``threads`` determines the number of threads to use for parallel
|
||||
encoding. By default, the plugin will detect the number of processors
|
||||
available and use them all.
|
||||
|
|
@ -57,6 +60,6 @@ Here's an example configuration::
|
|||
[convert]
|
||||
embed: false
|
||||
max_bitrate: 200
|
||||
opts: -V4
|
||||
opts: -aq 4
|
||||
dest: /home/user/MusicForPhone
|
||||
threads: 4
|
||||
|
|
|
|||
67
docs/plugins/echonest_tempo.rst
Normal file
67
docs/plugins/echonest_tempo.rst
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
EchoNest Tempo Plugin
|
||||
=====================
|
||||
|
||||
The ``echonest_tempo`` plugin fetches and stores a track's tempo (the "bpm"
|
||||
field) from the `EchoNest API`_
|
||||
|
||||
.. _EchoNest API: http://developer.echonest.com/
|
||||
|
||||
Installing Dependencies
|
||||
-----------------------
|
||||
|
||||
This plugin requires the pyechonest library in order to talk to the EchoNest
|
||||
API.
|
||||
|
||||
There are packages for most major linux distributions, you can download the
|
||||
library from the Echo Nest, or you can install the library from `pip`_,
|
||||
like so::
|
||||
|
||||
$ pip install pyechonest
|
||||
|
||||
.. _pip: http://pip.openplans.org/
|
||||
|
||||
Configuring
|
||||
-----------
|
||||
|
||||
Beets includes its own Echo Nest API key, but you can `apply for your own`_ for
|
||||
free from the EchoNest. To specify your own API key, add the key to your
|
||||
:doc:`/reference/config` as the value ``apikey`` in a section called
|
||||
``echonest_tempo`` like so::
|
||||
|
||||
[echonest_tempo]
|
||||
apikey=YOUR_API_KEY
|
||||
|
||||
In addition, the ``autofetch`` config option lets you disable automatic tempo
|
||||
fetching during import. To do so, add this to your ``~/.beetsconfig``::
|
||||
|
||||
[echonest_tempo]
|
||||
autofetch: no
|
||||
|
||||
.. _apply for your own: http://developer.echonest.com/account/register
|
||||
|
||||
Fetch Tempo During Import
|
||||
-------------------------
|
||||
|
||||
To automatically fetch the tempo for songs you import, just enable the plugin
|
||||
by putting ``echonest_tempo`` on your config file's ``plugins`` line (see
|
||||
:doc:`/plugins/index`), along with adding your EchoNest API key to your
|
||||
``~/.beetsconfig``. When importing new files, beets will now fetch the
|
||||
tempo for files that don't already have them. The bpm field will be stored in
|
||||
the beets database. If the ``import_write`` config option is on, then the
|
||||
tempo will also be written to the files' tags.
|
||||
|
||||
This behavior can be disabled with the ``autofetch`` config option (see below).
|
||||
|
||||
Fetching Tempo Manually
|
||||
-----------------------
|
||||
|
||||
The ``tempo`` command provided by this plugin fetches tempos for
|
||||
items that match a query (see :doc:`/reference/query`). For example,
|
||||
``beet tempo magnetic fields absolutely cuckoo`` will get the tempo for the
|
||||
appropriate Magnetic Fields song, ``beet tempo magnetic fields`` will get
|
||||
tempos for all my tracks by that band, and ``beet tempo`` will get tempos for
|
||||
my entire library. The tempos will be added to the beets database and, if
|
||||
``import_write`` is on, embedded into files' metadata.
|
||||
|
||||
The ``-p`` option to the ``tempo`` command makes it print tempos out to the
|
||||
console so you can view the fetched (or previously-stored) tempos.
|
||||
|
|
@ -53,7 +53,7 @@ art is resized. Server-side resizing can also be slower than local resizing, so
|
|||
consider installing one of the two backends for better performance.
|
||||
|
||||
When using ImageMagic, beets looks for the ``convert`` executable in your path.
|
||||
On some versions Windows, the program can be shadowed by a system-provided
|
||||
On some versions of Windows, the program can be shadowed by a system-provided
|
||||
``convert.exe``. On these systems, you may need to modify your ``%PATH%``
|
||||
environment variable so that ImageMagick comes first or use PIL instead.
|
||||
|
||||
|
|
|
|||
|
|
@ -3,25 +3,25 @@ IHate Plugin
|
|||
|
||||
The ``ihate`` plugin allows you to automatically skip things you hate during
|
||||
import or warn you about them. It supports album, artist and genre patterns.
|
||||
Also there is whitelist to avoid skipping bands you still like. There are two
|
||||
groups: warn and skip. Skip group is checked first. Whitelist overrides any
|
||||
There also is a whitelist to avoid skipping bands you still like. There are two
|
||||
groups: warn and skip. The skip group is checked first. Whitelist overrides any
|
||||
other patterns.
|
||||
|
||||
To use plugin, enable it by including ``ihate`` into ``plugins`` line of
|
||||
To use the plugin, enable it by including ``ihate`` in the ``plugins`` line of
|
||||
your beets config::
|
||||
|
||||
[beets]
|
||||
plugins = ihate
|
||||
|
||||
You need to configure plugin before use, so add following section into config
|
||||
file and adjust it to your needs::
|
||||
You need to configure the plugin before use, so add the following section into
|
||||
your config file and adjust it to your needs::
|
||||
|
||||
[ihate]
|
||||
# you will be warned about these suspicious genres/artists (regexps):
|
||||
warn_genre=rnb soul power\smetal
|
||||
warn_artist=bad\band another\sbad\sband
|
||||
warn_album=tribute\sto
|
||||
# if you don't like genre in general, but accept some band playing it,
|
||||
# if you don't like a genre in general, but accept some band playing it,
|
||||
# add exceptions here:
|
||||
warn_whitelist=hate\sexception
|
||||
# never import any of this:
|
||||
|
|
@ -31,5 +31,5 @@ file and adjust it to your needs::
|
|||
# but import this:
|
||||
skip_whitelist=
|
||||
|
||||
Note: plugin will trust you decision in 'as-is' mode.
|
||||
Note: The plugin will trust your decision in 'as-is' mode.
|
||||
|
||||
|
|
@ -37,6 +37,7 @@ disabled by default, but you can turn them on as described above.
|
|||
|
||||
chroma
|
||||
lyrics
|
||||
echonest_tempo
|
||||
bpd
|
||||
mpdupdate
|
||||
fetchart
|
||||
|
|
@ -55,6 +56,7 @@ disabled by default, but you can turn them on as described above.
|
|||
zero
|
||||
ihate
|
||||
convert
|
||||
info
|
||||
|
||||
Autotagger Extensions
|
||||
''''''''''''''''''''''
|
||||
|
|
@ -66,6 +68,7 @@ Metadata
|
|||
''''''''
|
||||
|
||||
* :doc:`lyrics`: Automatically fetch song lyrics.
|
||||
* :doc:`echonest_tempo`: Automatically fetch song tempos (bpm).
|
||||
* :doc:`lastgenre`: Fetch genres based on Last.fm tags.
|
||||
* :doc:`fetchart`: Fetch album cover art from various sources.
|
||||
* :doc:`embedart`: Embed album art images into files' metadata.
|
||||
|
|
@ -98,6 +101,7 @@ Miscellaneous
|
|||
* :doc:`bpd`: A music player for your beets library that emulates `MPD`_ and is
|
||||
compatible with `MPD clients`_.
|
||||
* :doc:`convert`: Converts parts of your collection to an external directory
|
||||
* :doc:`info`: Print music files' tags to the console.
|
||||
|
||||
.. _MPD: http://mpd.wikia.com/
|
||||
.. _MPD clients: http://mpd.wikia.com/wiki/Clients
|
||||
|
|
|
|||
16
docs/plugins/info.rst
Normal file
16
docs/plugins/info.rst
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
Info Plugin
|
||||
===========
|
||||
|
||||
The ``info`` plugin provides a command that dumps the current tag values for
|
||||
any file format supported by beets. It works like a supercharged version of
|
||||
`mp3info`_ or `id3v2`_.
|
||||
|
||||
Enable the plugin and then type::
|
||||
|
||||
$ beet info /path/to/music.flac
|
||||
|
||||
and the plugin will enumerate all the tags in the specified file. It also
|
||||
accepts multiple filenames in a single command-line.
|
||||
|
||||
.. _id3v2: http://id3v2.sourceforge.net
|
||||
.. _mp3info: http://www.ibiblio.org/mp3info/
|
||||
|
|
@ -157,9 +157,10 @@ art_filename
|
|||
~~~~~~~~~~~~
|
||||
|
||||
When importing album art, the name of the file (without extension) where the
|
||||
cover art image should be placed. Defaults to ``cover`` (i.e., images will
|
||||
be named ``cover.jpg`` or ``cover.png`` and placed in the album's
|
||||
directory).
|
||||
cover art image should be placed. This is a template string, so you can use any
|
||||
of the syntax available to :doc:`/reference/pathformat`. Defaults to ``cover``
|
||||
(i.e., images will be named ``cover.jpg`` or ``cover.png`` and placed in the
|
||||
album's directory).
|
||||
|
||||
plugins
|
||||
~~~~~~~
|
||||
|
|
|
|||
2
setup.py
2
setup.py
|
|
@ -42,7 +42,7 @@ if 'sdist' in sys.argv:
|
|||
shutil.copytree(os.path.join(docdir, '_build', 'man'), mandir)
|
||||
|
||||
setup(name='beets',
|
||||
version='1.0b16',
|
||||
version='1.0rc1',
|
||||
description='music tagger and library organizer',
|
||||
author='Adrian Sampson',
|
||||
author_email='adrian@radbox.org',
|
||||
|
|
|
|||
|
|
@ -30,24 +30,24 @@ class MockHeaders(object):
|
|||
def gettype(self):
|
||||
return self.typeval
|
||||
class MockUrlRetrieve(object):
|
||||
def __init__(self, pathval, typeval):
|
||||
def __init__(self, typeval, pathval='fetched_path'):
|
||||
self.pathval = pathval
|
||||
self.headers = MockHeaders(typeval)
|
||||
self.fetched = None
|
||||
def __call__(self, url):
|
||||
def __call__(self, url, filename=None):
|
||||
self.fetched = url
|
||||
return self.pathval, self.headers
|
||||
return filename or self.pathval, self.headers
|
||||
|
||||
class FetchImageTest(unittest.TestCase):
|
||||
def test_invalid_type_returns_none(self):
|
||||
fetchart.urllib.urlretrieve = MockUrlRetrieve('path', '')
|
||||
fetchart.urllib.urlretrieve = MockUrlRetrieve('')
|
||||
artpath = fetchart._fetch_image('http://example.com')
|
||||
self.assertEqual(artpath, None)
|
||||
|
||||
def test_jpeg_type_returns_path(self):
|
||||
fetchart.urllib.urlretrieve = MockUrlRetrieve('somepath', 'image/jpeg')
|
||||
fetchart.urllib.urlretrieve = MockUrlRetrieve('image/jpeg')
|
||||
artpath = fetchart._fetch_image('http://example.com')
|
||||
self.assertEqual(artpath, 'somepath')
|
||||
self.assertNotEqual(artpath, None)
|
||||
|
||||
class FSArtTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
|
|
@ -90,11 +90,10 @@ class CombinedTest(unittest.TestCase):
|
|||
return StringIO.StringIO(self.page_text)
|
||||
|
||||
def test_main_interface_returns_amazon_art(self):
|
||||
fetchart.urllib.urlretrieve = \
|
||||
MockUrlRetrieve('anotherpath', 'image/jpeg')
|
||||
fetchart.urllib.urlretrieve = MockUrlRetrieve('image/jpeg')
|
||||
album = _common.Bag(asin='xxxx')
|
||||
artpath = fetchart.art_for_album(album, None)
|
||||
self.assertEqual(artpath, 'anotherpath')
|
||||
self.assertNotEqual(artpath, None)
|
||||
|
||||
def test_main_interface_returns_none_for_missing_asin_and_path(self):
|
||||
album = _common.Bag()
|
||||
|
|
@ -103,43 +102,40 @@ class CombinedTest(unittest.TestCase):
|
|||
|
||||
def test_main_interface_gives_precedence_to_fs_art(self):
|
||||
_common.touch(os.path.join(self.dpath, 'a.jpg'))
|
||||
fetchart.urllib.urlretrieve = \
|
||||
MockUrlRetrieve('anotherpath', 'image/jpeg')
|
||||
fetchart.urllib.urlretrieve = MockUrlRetrieve('image/jpeg')
|
||||
album = _common.Bag(asin='xxxx')
|
||||
artpath = fetchart.art_for_album(album, self.dpath)
|
||||
self.assertEqual(artpath, os.path.join(self.dpath, 'a.jpg'))
|
||||
|
||||
def test_main_interface_falls_back_to_amazon(self):
|
||||
fetchart.urllib.urlretrieve = \
|
||||
MockUrlRetrieve('anotherpath', 'image/jpeg')
|
||||
fetchart.urllib.urlretrieve = MockUrlRetrieve('image/jpeg')
|
||||
album = _common.Bag(asin='xxxx')
|
||||
artpath = fetchart.art_for_album(album, self.dpath)
|
||||
self.assertEqual(artpath, 'anotherpath')
|
||||
self.assertNotEqual(artpath, None)
|
||||
self.assertFalse(artpath.startswith(self.dpath))
|
||||
|
||||
def test_main_interface_tries_amazon_before_aao(self):
|
||||
fetchart.urllib.urlretrieve = \
|
||||
MockUrlRetrieve('anotherpath', 'image/jpeg')
|
||||
fetchart.urllib.urlretrieve = MockUrlRetrieve('image/jpeg')
|
||||
album = _common.Bag(asin='xxxx')
|
||||
fetchart.art_for_album(album, self.dpath)
|
||||
self.assertFalse(self.urlopen_called)
|
||||
|
||||
def test_main_interface_falls_back_to_aao(self):
|
||||
fetchart.urllib.urlretrieve = \
|
||||
MockUrlRetrieve('anotherpath', 'text/html')
|
||||
fetchart.urllib.urlretrieve = MockUrlRetrieve('text/html')
|
||||
album = _common.Bag(asin='xxxx')
|
||||
fetchart.art_for_album(album, self.dpath)
|
||||
self.assertTrue(self.urlopen_called)
|
||||
|
||||
def test_main_interface_uses_caa_when_mbid_available(self):
|
||||
mock_retrieve = MockUrlRetrieve('anotherpath', 'image/jpeg')
|
||||
mock_retrieve = MockUrlRetrieve('image/jpeg')
|
||||
fetchart.urllib.urlretrieve = mock_retrieve
|
||||
album = _common.Bag(mb_albumid='releaseid', asin='xxxx')
|
||||
artpath = fetchart.art_for_album(album, None)
|
||||
self.assertEqual(artpath, 'anotherpath')
|
||||
self.assertNotEqual(artpath, None)
|
||||
self.assertTrue('coverartarchive.org' in mock_retrieve.fetched)
|
||||
|
||||
def test_local_only_does_not_access_network(self):
|
||||
mock_retrieve = MockUrlRetrieve('anotherpath', 'image/jpeg')
|
||||
mock_retrieve = MockUrlRetrieve('image/jpeg')
|
||||
fetchart.urllib.urlretrieve = mock_retrieve
|
||||
album = _common.Bag(mb_albumid='releaseid', asin='xxxx')
|
||||
artpath = fetchart.art_for_album(album, self.dpath, local_only=True)
|
||||
|
|
@ -149,7 +145,7 @@ class CombinedTest(unittest.TestCase):
|
|||
|
||||
def test_local_only_gets_fs_image(self):
|
||||
_common.touch(os.path.join(self.dpath, 'a.jpg'))
|
||||
mock_retrieve = MockUrlRetrieve('anotherpath', 'image/jpeg')
|
||||
mock_retrieve = MockUrlRetrieve('image/jpeg')
|
||||
fetchart.urllib.urlretrieve = mock_retrieve
|
||||
album = _common.Bag(mb_albumid='releaseid', asin='xxxx')
|
||||
artpath = fetchart.art_for_album(album, self.dpath, local_only=True)
|
||||
|
|
|
|||
|
|
@ -68,6 +68,17 @@ class PluralityTest(unittest.TestCase):
|
|||
self.assertEqual(l_album, 'The White Album')
|
||||
self.assertTrue(artist_consensus)
|
||||
|
||||
def test_albumartist_consensus(self):
|
||||
items = [Item({'artist': 'tartist1', 'album': 'album',
|
||||
'albumartist': 'aartist'}),
|
||||
Item({'artist': 'tartist2', 'album': 'album',
|
||||
'albumartist': 'aartist'}),
|
||||
Item({'artist': 'tartist3', 'album': 'album',
|
||||
'albumartist': 'aartist'})]
|
||||
l_artist, l_album, artist_consensus = match.current_metadata(items)
|
||||
self.assertEqual(l_artist, 'aartist')
|
||||
self.assertFalse(artist_consensus)
|
||||
|
||||
def _make_item(title, track, artist='some artist'):
|
||||
return Item({
|
||||
'title': title, 'track': track,
|
||||
|
|
|
|||
|
|
@ -654,7 +654,7 @@ class MigrationTest(unittest.TestCase):
|
|||
c = new_lib._connection().cursor()
|
||||
c.execute("select * from items")
|
||||
row = c.fetchone()
|
||||
self.assertEqual(len(row), len(self.old_fields))
|
||||
self.assertEqual(len(row.keys()), len(self.old_fields))
|
||||
|
||||
def test_open_with_new_field_adds_column(self):
|
||||
new_lib = beets.library.Library(self.libfile,
|
||||
|
|
@ -662,7 +662,7 @@ class MigrationTest(unittest.TestCase):
|
|||
c = new_lib._connection().cursor()
|
||||
c.execute("select * from items")
|
||||
row = c.fetchone()
|
||||
self.assertEqual(len(row), len(self.new_fields))
|
||||
self.assertEqual(len(row.keys()), len(self.new_fields))
|
||||
|
||||
def test_open_with_fewer_fields_leaves_untouched(self):
|
||||
new_lib = beets.library.Library(self.libfile,
|
||||
|
|
@ -670,7 +670,7 @@ class MigrationTest(unittest.TestCase):
|
|||
c = new_lib._connection().cursor()
|
||||
c.execute("select * from items")
|
||||
row = c.fetchone()
|
||||
self.assertEqual(len(row), len(self.old_fields))
|
||||
self.assertEqual(len(row.keys()), len(self.old_fields))
|
||||
|
||||
def test_open_with_multiple_new_fields(self):
|
||||
new_lib = beets.library.Library(self.libfile,
|
||||
|
|
@ -678,7 +678,7 @@ class MigrationTest(unittest.TestCase):
|
|||
c = new_lib._connection().cursor()
|
||||
c.execute("select * from items")
|
||||
row = c.fetchone()
|
||||
self.assertEqual(len(row), len(self.newer_fields))
|
||||
self.assertEqual(len(row.keys()), len(self.newer_fields))
|
||||
|
||||
def test_open_old_db_adds_album_table(self):
|
||||
conn = sqlite3.connect(self.libfile)
|
||||
|
|
@ -918,6 +918,19 @@ class PathStringTest(unittest.TestCase):
|
|||
alb = self.lib.get_album(alb.id)
|
||||
self.assert_(isinstance(alb.artpath, str))
|
||||
|
||||
class PathTruncationTest(unittest.TestCase):
|
||||
def test_truncate_bytestring(self):
|
||||
p = util.truncate_path('abcde/fgh', posixpath, 4)
|
||||
self.assertEqual(p, 'abcd/fgh')
|
||||
|
||||
def test_truncate_unicode(self):
|
||||
p = util.truncate_path(u'abcde/fgh', posixpath, 4)
|
||||
self.assertEqual(p, u'abcd/fgh')
|
||||
|
||||
def test_truncate_preserves_extension(self):
|
||||
p = util.truncate_path(u'abcde/fgh.ext', posixpath, 5)
|
||||
self.assertEqual(p, u'abcde/f.ext')
|
||||
|
||||
class MtimeTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.ipath = os.path.join(_common.RSRC, 'testfile.mp3')
|
||||
|
|
|
|||
|
|
@ -23,128 +23,7 @@ import _common
|
|||
from _common import unittest
|
||||
import beets.mediafile
|
||||
|
||||
|
||||
def MakeReadingTest(path, correct_dict, field):
|
||||
class ReadingTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.f = beets.mediafile.MediaFile(path)
|
||||
def runTest(self):
|
||||
got = getattr(self.f, field)
|
||||
correct = correct_dict[field]
|
||||
message = field + ' incorrect (expected ' + repr(correct) + \
|
||||
', got ' + repr(got) + ') when testing ' + \
|
||||
os.path.basename(path)
|
||||
if isinstance(correct, float):
|
||||
self.assertAlmostEqual(got, correct, msg=message)
|
||||
else:
|
||||
self.assertEqual(got, correct, message)
|
||||
return ReadingTest
|
||||
|
||||
def MakeReadOnlyTest(path, field, value):
|
||||
class ReadOnlyTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.f = beets.mediafile.MediaFile(path)
|
||||
def runTest(self):
|
||||
got = getattr(self.f, field)
|
||||
fail_msg = field + ' incorrect (expected ' + \
|
||||
repr(value) + ', got ' + repr(got) + \
|
||||
') on ' + os.path.basename(path)
|
||||
if field == 'length':
|
||||
self.assertTrue(value-0.1 < got < value+0.1, fail_msg)
|
||||
else:
|
||||
self.assertEqual(got, value, fail_msg)
|
||||
return ReadOnlyTest
|
||||
|
||||
def MakeWritingTest(path, correct_dict, field, testsuffix='_test'):
|
||||
|
||||
class WritingTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# make a copy of the file we'll work on
|
||||
root, ext = os.path.splitext(path)
|
||||
self.tpath = root + testsuffix + ext
|
||||
shutil.copy(path, self.tpath)
|
||||
|
||||
# generate the new value we'll try storing
|
||||
if field == 'art':
|
||||
self.value = 'xxx'
|
||||
elif type(correct_dict[field]) is unicode:
|
||||
self.value = u'TestValue: ' + field
|
||||
elif type(correct_dict[field]) is int:
|
||||
self.value = correct_dict[field] + 42
|
||||
elif type(correct_dict[field]) is bool:
|
||||
self.value = not correct_dict[field]
|
||||
elif type(correct_dict[field]) is datetime.date:
|
||||
self.value = correct_dict[field] + datetime.timedelta(42)
|
||||
elif type(correct_dict[field]) is str:
|
||||
self.value = 'TestValue-' + str(field)
|
||||
elif type(correct_dict[field]) is float:
|
||||
self.value = 9.87
|
||||
else:
|
||||
raise ValueError('unknown field type ' + \
|
||||
str(type(correct_dict[field])))
|
||||
|
||||
def runTest(self):
|
||||
# write new tag
|
||||
a = beets.mediafile.MediaFile(self.tpath)
|
||||
setattr(a, field, self.value)
|
||||
a.save()
|
||||
|
||||
# verify ALL tags are correct with modification
|
||||
b = beets.mediafile.MediaFile(self.tpath)
|
||||
for readfield in correct_dict.keys():
|
||||
got = getattr(b, readfield)
|
||||
|
||||
# Make sure the modified field was changed correctly...
|
||||
if readfield == field:
|
||||
message = field + ' modified incorrectly (changed to ' + \
|
||||
repr(self.value) + ' but read ' + repr(got) + \
|
||||
') when testing ' + os.path.basename(path)
|
||||
if isinstance(self.value, float):
|
||||
self.assertAlmostEqual(got, self.value, msg=message)
|
||||
else:
|
||||
self.assertEqual(got, self.value, message)
|
||||
|
||||
# ... and that no other field was changed.
|
||||
else:
|
||||
# MPEG-4: ReplayGain not implented.
|
||||
if 'm4a' in path and readfield.startswith('rg_'):
|
||||
continue
|
||||
|
||||
# The value should be what it was originally most of the
|
||||
# time.
|
||||
correct = correct_dict[readfield]
|
||||
|
||||
# The date field, however, is modified when its components
|
||||
# change.
|
||||
if readfield=='date' and field in ('year', 'month', 'day'):
|
||||
try:
|
||||
correct = datetime.date(
|
||||
self.value if field=='year' else correct.year,
|
||||
self.value if field=='month' else correct.month,
|
||||
self.value if field=='day' else correct.day
|
||||
)
|
||||
except ValueError:
|
||||
correct = datetime.date.min
|
||||
# And vice-versa.
|
||||
if field=='date' and readfield in ('year', 'month', 'day'):
|
||||
correct = getattr(self.value, readfield)
|
||||
|
||||
message = readfield + ' changed when it should not have' \
|
||||
' (expected ' + repr(correct) + ', got ' + \
|
||||
repr(got) + ') when modifying ' + field + \
|
||||
' in ' + os.path.basename(path)
|
||||
if isinstance(correct, float):
|
||||
self.assertAlmostEqual(got, correct, msg=message)
|
||||
else:
|
||||
self.assertEqual(got, correct, message)
|
||||
|
||||
def tearDown(self):
|
||||
if os.path.exists(self.tpath):
|
||||
os.remove(self.tpath)
|
||||
|
||||
return WritingTest
|
||||
|
||||
correct_dicts = {
|
||||
CORRECT_DICTS = {
|
||||
|
||||
# All of the fields iTunes supports that we do also.
|
||||
'full': {
|
||||
|
|
@ -253,7 +132,7 @@ correct_dicts = {
|
|||
|
||||
}
|
||||
|
||||
read_only_correct_dicts = {
|
||||
READ_ONLY_CORRECT_DICTS = {
|
||||
'full.mp3': {
|
||||
'length': 1.0,
|
||||
'bitrate': 80000,
|
||||
|
|
@ -318,21 +197,7 @@ read_only_correct_dicts = {
|
|||
},
|
||||
}
|
||||
|
||||
def suite_for_file(path, correct_dict, writing=True):
|
||||
s = unittest.TestSuite()
|
||||
for field in correct_dict:
|
||||
if 'm4a' in path and field.startswith('rg_'):
|
||||
# MPEG-4 files: ReplayGain values not implemented.
|
||||
continue
|
||||
s.addTest(MakeReadingTest(path, correct_dict, field)())
|
||||
if writing and \
|
||||
not ( field == 'month' and correct_dict['year'] == 0
|
||||
or field == 'day' and correct_dict['month'] == 0):
|
||||
# ensure that we don't test fields that can't be modified
|
||||
s.addTest(MakeWritingTest(path, correct_dict, field)())
|
||||
return s
|
||||
|
||||
test_files = {
|
||||
TEST_FILES = {
|
||||
'm4a': ['full', 'partial', 'min'],
|
||||
'mp3': ['full', 'partial', 'min'],
|
||||
'flac': ['full', 'partial', 'min'],
|
||||
|
|
@ -342,39 +207,210 @@ test_files = {
|
|||
'mpc': ['full'],
|
||||
}
|
||||
|
||||
def suite():
|
||||
s = unittest.TestSuite()
|
||||
class AllFilesMixin(object):
|
||||
"""This is a dumb bit of copypasta but unittest has no supported
|
||||
method of generating tests at runtime.
|
||||
"""
|
||||
def test_m4a_full(self):
|
||||
self._run('full', 'm4a')
|
||||
|
||||
# General tests.
|
||||
for kind, tagsets in test_files.items():
|
||||
for tagset in tagsets:
|
||||
path = os.path.join(_common.RSRC, tagset + '.' + kind)
|
||||
correct_dict = correct_dicts[tagset]
|
||||
for test in suite_for_file(path, correct_dict):
|
||||
s.addTest(test)
|
||||
def test_m4a_partial(self):
|
||||
self._run('partial', 'm4a')
|
||||
|
||||
# Special test for missing ID3 tag.
|
||||
for test in suite_for_file(os.path.join(_common.RSRC, 'empty.mp3'),
|
||||
correct_dicts['empty'],
|
||||
writing=False):
|
||||
s.addTest(test)
|
||||
def test_m4a_min(self):
|
||||
self._run('min', 'm4a')
|
||||
|
||||
def test_mp3_full(self):
|
||||
self._run('full', 'mp3')
|
||||
|
||||
def test_mp3_partial(self):
|
||||
self._run('partial', 'mp3')
|
||||
|
||||
def test_mp3_min(self):
|
||||
self._run('min', 'mp3')
|
||||
|
||||
def test_flac_full(self):
|
||||
self._run('full', 'flac')
|
||||
|
||||
def test_flac_partial(self):
|
||||
self._run('partial', 'flac')
|
||||
|
||||
def test_flac_min(self):
|
||||
self._run('min', 'flac')
|
||||
|
||||
def test_ogg(self):
|
||||
self._run('full', 'ogg')
|
||||
|
||||
def test_ape(self):
|
||||
self._run('full', 'ape')
|
||||
|
||||
def test_wv(self):
|
||||
self._run('full', 'wv')
|
||||
|
||||
def test_mpc(self):
|
||||
self._run('full', 'mpc')
|
||||
|
||||
# Special test for advanced release date.
|
||||
for test in suite_for_file(os.path.join(_common.RSRC, 'date.mp3'),
|
||||
correct_dicts['date']):
|
||||
s.addTest(test)
|
||||
def test_date_mp3(self):
|
||||
self._run('date', 'mp3')
|
||||
|
||||
# Read-only attribute tests.
|
||||
for fname, correct_dict in read_only_correct_dicts.iteritems():
|
||||
path = os.path.join(_common.RSRC, fname)
|
||||
for field, value in correct_dict.iteritems():
|
||||
s.addTest(MakeReadOnlyTest(path, field, value)())
|
||||
class ReadingTest(unittest.TestCase, AllFilesMixin):
|
||||
def _read_field(self, mf, correct_dict, field):
|
||||
got = getattr(mf, field)
|
||||
correct = correct_dict[field]
|
||||
message = field + ' incorrect (expected ' + repr(correct) + \
|
||||
', got ' + repr(got) + ')'
|
||||
if isinstance(correct, float):
|
||||
self.assertAlmostEqual(got, correct, msg=message)
|
||||
else:
|
||||
self.assertEqual(got, correct, message)
|
||||
|
||||
def _run(self, tagset, kind):
|
||||
correct_dict = CORRECT_DICTS[tagset]
|
||||
path = os.path.join(_common.RSRC, tagset + '.' + kind)
|
||||
f = beets.mediafile.MediaFile(path)
|
||||
for field in correct_dict:
|
||||
if 'm4a' in path and field.startswith('rg_'):
|
||||
# MPEG-4 files: ReplayGain values not implemented.
|
||||
continue
|
||||
self._read_field(f, correct_dict, field)
|
||||
|
||||
return s
|
||||
# Special test for missing ID3 tag.
|
||||
def test_empy_mp3(self):
|
||||
self._run('empty', 'mp3')
|
||||
|
||||
def test_nose_suite():
|
||||
for test in suite():
|
||||
yield test
|
||||
class WritingTest(unittest.TestCase, AllFilesMixin):
|
||||
def _write_field(self, tpath, field, value, correct_dict):
|
||||
# Write new tag.
|
||||
a = beets.mediafile.MediaFile(tpath)
|
||||
setattr(a, field, value)
|
||||
a.save()
|
||||
|
||||
# Verify ALL tags are correct with modification.
|
||||
b = beets.mediafile.MediaFile(tpath)
|
||||
for readfield in correct_dict.keys():
|
||||
got = getattr(b, readfield)
|
||||
|
||||
# Make sure the modified field was changed correctly...
|
||||
if readfield == field:
|
||||
message = field + ' modified incorrectly (changed to ' + \
|
||||
repr(value) + ' but read ' + repr(got) + ')'
|
||||
if isinstance(value, float):
|
||||
self.assertAlmostEqual(got, value, msg=message)
|
||||
else:
|
||||
self.assertEqual(got, value, message)
|
||||
|
||||
# ... and that no other field was changed.
|
||||
else:
|
||||
# MPEG-4: ReplayGain not implented.
|
||||
if 'm4a' in tpath and readfield.startswith('rg_'):
|
||||
continue
|
||||
|
||||
# The value should be what it was originally most of the
|
||||
# time.
|
||||
correct = correct_dict[readfield]
|
||||
|
||||
# The date field, however, is modified when its components
|
||||
# change.
|
||||
if readfield=='date' and field in ('year', 'month', 'day'):
|
||||
try:
|
||||
correct = datetime.date(
|
||||
value if field=='year' else correct.year,
|
||||
value if field=='month' else correct.month,
|
||||
value if field=='day' else correct.day
|
||||
)
|
||||
except ValueError:
|
||||
correct = datetime.date.min
|
||||
# And vice-versa.
|
||||
if field=='date' and readfield in ('year', 'month', 'day'):
|
||||
correct = getattr(value, readfield)
|
||||
|
||||
message = readfield + ' changed when it should not have' \
|
||||
' (expected ' + repr(correct) + ', got ' + \
|
||||
repr(got) + ') when modifying ' + field
|
||||
if isinstance(correct, float):
|
||||
self.assertAlmostEqual(got, correct, msg=message)
|
||||
else:
|
||||
self.assertEqual(got, correct, message)
|
||||
|
||||
def _run(self, tagset, kind):
|
||||
correct_dict = CORRECT_DICTS[tagset]
|
||||
path = os.path.join(_common.RSRC, tagset + '.' + kind)
|
||||
|
||||
for field in correct_dict:
|
||||
if field == 'month' and correct_dict['year'] == 0 or \
|
||||
field == 'day' and correct_dict['month'] == 0:
|
||||
continue
|
||||
|
||||
# Generate the new value we'll try storing.
|
||||
if field == 'art':
|
||||
value = 'xxx'
|
||||
elif type(correct_dict[field]) is unicode:
|
||||
value = u'TestValue: ' + field
|
||||
elif type(correct_dict[field]) is int:
|
||||
value = correct_dict[field] + 42
|
||||
elif type(correct_dict[field]) is bool:
|
||||
value = not correct_dict[field]
|
||||
elif type(correct_dict[field]) is datetime.date:
|
||||
value = correct_dict[field] + datetime.timedelta(42)
|
||||
elif type(correct_dict[field]) is str:
|
||||
value = 'TestValue-' + str(field)
|
||||
elif type(correct_dict[field]) is float:
|
||||
value = 9.87
|
||||
else:
|
||||
raise ValueError('unknown field type ' + \
|
||||
str(type(correct_dict[field])))
|
||||
|
||||
# Make a copy of the file we'll work on.
|
||||
root, ext = os.path.splitext(path)
|
||||
tpath = root + '_test' + ext
|
||||
shutil.copy(path, tpath)
|
||||
|
||||
try:
|
||||
self._write_field(tpath, field, value, correct_dict)
|
||||
finally:
|
||||
os.remove(tpath)
|
||||
|
||||
class ReadOnlyTest(unittest.TestCase):
|
||||
def _read_field(self, mf, field, value):
|
||||
got = getattr(mf, field)
|
||||
fail_msg = field + ' incorrect (expected ' + \
|
||||
repr(value) + ', got ' + repr(got) + ')'
|
||||
if field == 'length':
|
||||
self.assertTrue(value-0.1 < got < value+0.1, fail_msg)
|
||||
else:
|
||||
self.assertEqual(got, value, fail_msg)
|
||||
|
||||
def _run(self, filename):
|
||||
path = os.path.join(_common.RSRC, filename)
|
||||
f = beets.mediafile.MediaFile(path)
|
||||
correct_dict = READ_ONLY_CORRECT_DICTS[filename]
|
||||
for field, value in correct_dict.items():
|
||||
self._read_field(f, field, value)
|
||||
|
||||
def test_mp3(self):
|
||||
self._run('full.mp3')
|
||||
|
||||
def test_m4a(self):
|
||||
self._run('full.m4a')
|
||||
|
||||
def test_flac(self):
|
||||
self._run('full.flac')
|
||||
|
||||
def test_ogg(self):
|
||||
self._run('full.ogg')
|
||||
|
||||
def test_ape(self):
|
||||
self._run('full.ape')
|
||||
|
||||
def test_wv(self):
|
||||
self._run('full.wv')
|
||||
|
||||
def test_mpc(self):
|
||||
self._run('full.mpc')
|
||||
|
||||
def suite():
|
||||
return unittest.TestLoader().loadTestsFromName(__name__)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(defaultTest='suite')
|
||||
|
|
|
|||
|
|
@ -704,8 +704,9 @@ class ShowChangeTest(unittest.TestCase):
|
|||
def test_item_data_change_title_missing_with_unicode_filename(self):
|
||||
self.items[0].title = ''
|
||||
self.items[0].path = u'/path/to/caf\xe9.mp3'.encode('utf8')
|
||||
msg = self._show_change()
|
||||
self.assertTrue(u'caf\xe9.mp3 -> the title' in msg.decode('utf8'))
|
||||
msg = self._show_change().decode('utf8')
|
||||
self.assertTrue(u'caf\xe9.mp3 -> the title' in msg
|
||||
or u'caf.mp3 ->' in msg)
|
||||
|
||||
class DefaultPathTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
|
|
|
|||
Loading…
Reference in a new issue