merge lots of work on the stable branch

This commit is contained in:
Adrian Sampson 2012-12-12 19:14:30 -08:00
commit e17cd6beba
31 changed files with 618 additions and 288 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

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

View 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(),
)

View file

@ -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

View file

@ -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):

View file

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

View file

@ -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
View 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)

View file

@ -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])

View file

@ -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('&nbsp;', ' ')
out = text.replace(u'&nbsp;', 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):

View file

@ -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).
"""

View file

@ -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

View file

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

View file

@ -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

View 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.

View file

@ -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.

View file

@ -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.

View file

@ -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
View 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/

View file

@ -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
~~~~~~~

View file

@ -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',

View file

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

View file

@ -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,

View file

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

View file

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

View file

@ -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):