diff --git a/README.rst b/README.rst index 8780e95a8..dc28d6d7e 100644 --- a/README.rst +++ b/README.rst @@ -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 diff --git a/beets/__init__.py b/beets/__init__.py index cf437bac2..e7108035e 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -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 ' import beets.library diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 9cdb02f40..7221af22d 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -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 diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 5734fbc56..7aaa6595f 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -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 diff --git a/beets/importer.py b/beets/importer.py index 21dcd267b..277f648fd 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -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 diff --git a/beets/library.py b/beets/library.py index b1b81e75f..9a28067bc 100644 --- a/beets/library.py +++ b/beets/library.py @@ -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. diff --git a/beets/mediafile.py b/beets/mediafile.py index b5e0acce2..4f5be5ff4 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -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') diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 3ae33a0ed..571df6ded 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -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(), ) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 76f55a660..47b52933d 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -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 diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 3448d104f..12a338439 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -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): diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index 9b3229dd1..e2eb4af3a 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -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))) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 2afa658a7..b9259d2fb 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -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, diff --git a/beetsplug/echonest_tempo.py b/beetsplug/echonest_tempo.py new file mode 100644 index 000000000..df9ff16b4 --- /dev/null +++ b/beetsplug/echonest_tempo.py @@ -0,0 +1,107 @@ +# This file is part of beets. +# Copyright 2012, David Brenner +# +# 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) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 567d8a64b..57299dca9 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -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]) diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 2a801436e..eadb5d6b6 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -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): diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index f7e041634..c84226b4c 100755 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -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). """ diff --git a/docs/changelog.rst b/docs/changelog.rst index 48de872b3..57974231b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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 + `_-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 diff --git a/docs/conf.py b/docs/conf.py index 95cd09e47..09790f717 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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' diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index 47a4d5621..37345bb75 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -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 " is equivalent to the LAME option "-V + ".) If you want to specify a bitrate, use "-ab ". 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 diff --git a/docs/plugins/echonest_tempo.rst b/docs/plugins/echonest_tempo.rst new file mode 100644 index 000000000..0b1df07ee --- /dev/null +++ b/docs/plugins/echonest_tempo.rst @@ -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. diff --git a/docs/plugins/fetchart.rst b/docs/plugins/fetchart.rst index 3f02937e8..518c1eac3 100644 --- a/docs/plugins/fetchart.rst +++ b/docs/plugins/fetchart.rst @@ -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. diff --git a/docs/plugins/ihate.rst b/docs/plugins/ihate.rst index 24e46fd14..0d8e9e3b4 100644 --- a/docs/plugins/ihate.rst +++ b/docs/plugins/ihate.rst @@ -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. \ No newline at end of file diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index cd94cf104..1132de456 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -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 diff --git a/docs/plugins/info.rst b/docs/plugins/info.rst new file mode 100644 index 000000000..38ee40085 --- /dev/null +++ b/docs/plugins/info.rst @@ -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/ diff --git a/docs/reference/config.rst b/docs/reference/config.rst index eb1954a2d..102d7ef2b 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -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 ~~~~~~~ diff --git a/setup.py b/setup.py index 17bd7a016..662c79445 100755 --- a/setup.py +++ b/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', diff --git a/test/test_art.py b/test/test_art.py index fb9e5258b..b53aae59d 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -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) diff --git a/test/test_autotag.py b/test/test_autotag.py index a07bad33c..be331370f 100644 --- a/test/test_autotag.py +++ b/test/test_autotag.py @@ -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, diff --git a/test/test_db.py b/test/test_db.py index 5df34f45e..4d451c3f1 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -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') diff --git a/test/test_mediafile_basic.py b/test/test_mediafile_basic.py index b05693f0d..243fcd702 100644 --- a/test/test_mediafile_basic.py +++ b/test/test_mediafile_basic.py @@ -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') diff --git a/test/test_ui.py b/test/test_ui.py index eea86adf8..15d52175a 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -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):