Commits

Fabrice Laporte committed 591942f Merge

Merge branch 'master' of https://github.com/sampsyo/beets

Comments (0)

Files changed (37)

 c84744f4519be7416dc1653142f1763f406d6896 1.0rc1
 f3cd4c138c6f40dc324a23bf01c4c7d97766477e 1.0rc2
 6f29c0f4dc7025e8d8216ea960000c353886c4f4 v1.1.0-beta.1
+f28ea9e2ef8d39913d79dbba73db280ff0740c50 v1.1.0-beta.2

beets/__init__.py

 # The above copyright notice and this permission notice shall be
 # included in all copies or substantial portions of the Software.
 
-__version__ = '1.1.0-beta.2'
+__version__ = '1.1.0-beta.3'
 __author__ = 'Adrian Sampson <adrian@radbox.org>'
 
 import beets.library

beets/autotag/__init__.py

 
 # Parts of external interface.
 from .hooks import AlbumInfo, TrackInfo, AlbumMatch, TrackMatch
-from .match import AutotagError
 from .match import tag_item, tag_album
 from .match import recommendation
 
     collapse_pat = collapse_paths = collapse_items = None
 
     for root, dirs, files in sorted_walk(path,
-                                         ignore=config['ignore'].as_str_seq()):
+                                         ignore=config['ignore'].as_str_seq(),
+                                         logger=log):
         # Get a list of items in the directory.
         items = []
         for filename in files:

beets/autotag/match.py

 TRACK_WEIGHT = 1.0
 # The weight of a missing track.
 MISSING_WEIGHT = 0.9
-# The weight of an extra (umatched) track.
+# The weight of an extra (unmatched) track.
 UNMATCHED_WEIGHT = 0.6
 # These distances are components of the track distance (that is, they
 # compete against each other but not ARTIST_WEIGHT and ALBUM_WEIGHT;
 # differing artists.
 VA_ARTISTS = (u'', u'various artists', u'va', u'unknown')
 
-# Autotagging exceptions.
-class AutotagError(Exception):
-    pass
-
 # Global logger.
 log = logging.getLogger('beets')
 
     `album_info.tracks`.
     """
     cur_artist, cur_album, _ = current_metadata(items)
-    cur_artist = cur_artist or ''
-    cur_album = cur_album or ''
+    cur_artist = cur_artist or u''
+    cur_album = cur_album or u''
 
     # These accumulate the possible distance components. The final
     # distance will be dist/dist_max.
         - A recommendation.
     If search_artist and search_album or search_id are provided, then
     they are used as search terms in place of the current metadata.
-    May raise an AutotagError if existing metadata is insufficient.
     """
     # Get current metadata.
     cur_artist, cur_album, artist_consensus = current_metadata(items)

beets/autotag/mb.py

                     'labels', 'artist-credits']
 TRACK_INCLUDES = ['artists']
 
-# python-musicbrainz-ngs search functions: tolerate different API versions.
-if hasattr(musicbrainzngs, 'release_search'):
-    # Old API names.
-    _mb_release_search = musicbrainzngs.release_search
-    _mb_recording_search = musicbrainzngs.recording_search
-else:
-    # New API names.
-    _mb_release_search = musicbrainzngs.search_releases
-    _mb_recording_search = musicbrainzngs.search_recordings
+# Only versions >= 0.3 of python-musicbrainz-ngs support artist aliases.
+if musicbrainzngs.musicbrainz._version >= '0.3':
+    RELEASE_INCLUDES.append('aliases')
+    TRACK_INCLUDES.append('aliases')
 
 def configure():
     """Set up the python-musicbrainz-ngs module according to settings
         config['musicbrainz']['ratelimit'].get(int),
     )
 
+def _preferred_alias(aliases):
+    """Given an list of alias structures for an artist credit, select
+    and return the user's preferred alias alias or None if no matching
+    alias is found.
+    """
+    if not aliases:
+        return
+
+    # Only consider aliases that have locales set.
+    aliases = [a for a in aliases if 'locale' in a]
+
+    # Search configured locales in order.
+    for locale in config['import']['languages'].as_str_seq():
+        # Find matching aliases for this locale.
+        matches = [a for a in aliases if a['locale'] == locale]
+        # Skip to the next locale if we have no matches
+        if not matches:
+            continue
+
+        # Find the aliases that have the primary flag set.
+        primaries = [a for a in matches if 'primary' in a]
+        # Take the primary if we have it, otherwise take the first
+        # match with the correct locale.
+        if primaries:
+            return primaries[0]
+        else:
+            return matches[0]
+
 def _flatten_artist_credit(credit):
     """Given a list representing an ``artist-credit`` block, flatten the
     data into a triple of joined artist name strings: canonical, sort, and
             artist_sort_parts.append(el)
 
         else:
+            alias = _preferred_alias(el['artist'].get('alias-list', ()))
+
             # An artist.
-            cur_artist_name = el['artist']['name']
+            if alias:
+                cur_artist_name = alias['alias']
+            else:
+                cur_artist_name = el['artist']['name']
             artist_parts.append(cur_artist_name)
 
             # Artist sort name.
-            if 'sort-name' in el['artist']:
+            if alias:
+                artist_sort_parts.append(alias['sort-name'])
+            elif 'sort-name' in el['artist']:
                 artist_sort_parts.append(el['artist']['sort-name'])
             else:
                 artist_sort_parts.append(cur_artist_name)
                             int(medium['position']),
                             int(track['position']))
             if track.get('title'):
-                # Track title may be distinct from underling recording
+                # Track title may be distinct from underlying recording
                 # title.
                 ti.title = track['title']
             ti.disctitle = disctitle
         return
 
     try:
-        res = _mb_release_search(limit=limit, **criteria)
+        res = musicbrainzngs.search_releases(limit=limit, **criteria)
     except musicbrainzngs.MusicBrainzError as exc:
         raise MusicBrainzAPIError(exc, 'release search', criteria,
                                   traceback.format_exc())
         return
 
     try:
-        res = _mb_recording_search(limit=limit, **criteria)
+        res = musicbrainzngs.search_recordings(limit=limit, **criteria)
     except musicbrainzngs.MusicBrainzError as exc:
         raise MusicBrainzAPIError(exc, 'recording search', criteria,
                                   traceback.format_exc())

beets/config_default.yaml

     quiet: no
     singletons: no
     default_action: apply
+    languages: []
+    detail: no
+    flat: no
 
 clutter: ["Thumbs.DB", ".DS_Store"]
-ignore: [".*", "*~"]
+ignore: [".*", "*~", "System Volume Information"]
 replace:
     '[\\/]': _
     '^\.': _

beets/importer.py

             yield ImportTask.item_task(item)
             continue
 
+        # A flat album import merges all items into one album.
+        if config['import']['flat'] and not config['import']['singletons']:
+            all_items = []
+            for _, items in autotag.albums_in_dir(toppath):
+                all_items += items
+            yield ImportTask(toppath, toppath, all_items)
+            yield ImportTask.done_sentinel(toppath)
+            continue
+
         # Produce paths under this directory.
         if _resume():
             resume_dir = resume_dirs.get(toppath)
         plugins.send('import_task_start', session=session, task=task)
 
         log.debug('Looking up: %s' % displayable_path(task.paths))
-        try:
-            task.set_candidates(*autotag.tag_album(task.items,
-                                                   config['import']['timid']))
-        except autotag.AutotagError:
-            task.set_null_candidates()
+        task.set_candidates(
+            *autotag.tag_album(task.items)
+        )
 
 def user_query(session):
     """A coroutine for interfacing with the user about the tagging
     elif key == 'samplerate':
         # Sample rate formatted as kHz.
         value = u'%ikHz' % ((value or 0) // 1000)
+    elif value is None:
+        value = u''
     else:
         value = unicode(value)
 

beets/mediafile.py

     try:
         soundcheck = soundcheck.replace(' ', '').decode('hex')
         soundcheck = struct.unpack('!iiiiiiiiii', soundcheck)
-    except struct.error:
+    except (struct.error, TypeError):
         # SoundCheck isn't in the format we expect, so return default
         # values.
         return 0.0, 0.0
     # compared to a reference value of 1000 to get our gain in dB. We
     # play it safe by using the larger of the two values (i.e., the most
     # attenuation).
-    gain = math.log10((max(*soundcheck[:2]) or 1000) / 1000.0) * -10
+    maxgain = max(soundcheck[:2])
+    if maxgain > 0:
+        gain = math.log10(maxgain / 1000.0) * -10
+    else:
+        # Invalid gain value found.
+        gain = 0.0
 
     # SoundCheck stores peak values as the actual value of the sample,
     # and again separately for the left and right channels. We need to
     """Makes a packed list of values subscriptable. To access the packed
     output after making changes, use packed_thing.items.
     """
-    def __init__(self, items, packstyle, none_val=0, out_type=int):
+    def __init__(self, items, packstyle, out_type=int):
         """Create a Packed object for subscripting the packed values in
         items. The items are packed using packstyle, which is a value
-        from the packing enum. none_val is returned from a request when
-        no suitable value is found in the items. Values are converted to
-        out_type before they are returned.
+        from the packing enum. Values are converted to out_type before
+        they are returned.
         """
         self.items = items
         self.packstyle = packstyle
-        self.none_val = none_val
         self.out_type = out_type
 
+        if out_type is int:
+            self.none_val = 0
+        elif out_type is float:
+            self.none_val = 0.0
+        else:
+            self.none_val = None
+
     def __getitem__(self, index):
         if not isinstance(index, int):
             raise TypeError('index must be an integer')
             return _safe_cast(self.out_type, out)
 
     def __setitem__(self, index, value):
+        # Interpret null values.
+        if value is None:
+            value = self.none_val
+
         if self.packstyle in (packing.SLASHED, packing.TUPLE, packing.SC):
             # SLASHED, TUPLE and SC are always two-item packings
             length = 2
             # Remove suffix.
             if style.suffix and isinstance(out, (str, unicode)):
                 if out.endswith(style.suffix):
-                    out = out[:len(style.suffix)]
+                    out = out[:-len(style.suffix)]
 
             # MPEG-4 freeform frames are (should be?) encoded as UTF-8.
             if obj.type == 'mp4' and style.key.startswith('----:') and \

beets/ui/commands.py

 
         if lhs != rhs:
             lines.append((lhs, rhs, lhs_width))
+        elif config['import']['detail']:
+            lines.append((lhs, '', lhs_width))
 
     # Print each track in two columns, or across two lines.
     col_width = (ui.term_width() - len(''.join([' * ', ' -> ']))) // 2
     if lines:
         max_width = max(w for _, _, w in lines)
         for lhs, rhs, lhs_width in lines:
-            if max_width > col_width:
+            if not rhs:
+                print_(u' * {0}'.format(lhs))
+            elif max_width > col_width:
                 print_(u' * %s ->\n   %s' % (lhs, rhs))
             else:
                 pad = max_width - lhs_width
             elif choice is importer.action.MANUAL:
                 # Try again with manual search terms.
                 search_artist, search_album = manual_search(False)
-                try:
-                    _, _, candidates, rec = \
-                        autotag.tag_album(task.items, search_artist,
-                                          search_album)
-                except autotag.AutotagError:
-                    candidates, rec = None, None
+                _, _, candidates, rec = autotag.tag_album(
+                    task.items, search_artist, search_album
+                )
             elif choice is importer.action.MANUAL_ID:
                 # Try a manually-entered ID.
                 search_id = manual_id(False)
                 if search_id:
-                    try:
-                        _, _, candidates, rec = \
-                            autotag.tag_album(task.items, search_id=search_id)
-                    except autotag.AutotagError:
-                        candidates, rec = None, None
+                    _, _, candidates, rec = autotag.tag_album(
+                        task.items, search_id=search_id
+                    )
             else:
                 # We have a candidate! Finish tagging. Here, choice is an
                 # AlbumMatch object.
     dest='resume', help="do not try to resume importing")
 import_cmd.parser.add_option('-q', '--quiet', action='store_true',
     dest='quiet', help="never prompt for input: skip albums instead")
-import_cmd.parser.add_option('-l', '--log', dest='logpath',
+import_cmd.parser.add_option('-l', '--log', dest='log',
     help='file to log untaggable albums for later review')
 import_cmd.parser.add_option('-s', '--singletons', action='store_true',
     help='import individual tracks instead of full albums')
     action='store_true', help='skip already-imported directories')
 import_cmd.parser.add_option('-I', '--noincremental', dest='incremental',
     action='store_false', help='do not skip already-imported directories')
+import_cmd.parser.add_option('--flat', dest='flat',
+    action='store_true', help='import an entire tree as a single album')
 def import_func(lib, opts, args):
     config['import'].set_args(opts)
 

beets/util/__init__.py

             out.insert(0, path)
     return out
 
-def sorted_walk(path, ignore=()):
-    """Like ``os.walk``, but yields things in case-insensitive sorted,
+def sorted_walk(path, ignore=(), logger=None):
+    """Like `os.walk`, but yields things in case-insensitive sorted,
     breadth-first order.  Directory and file names matching any glob
-    pattern in ``ignore`` are skipped.
+    pattern in `ignore` are skipped. If `logger` is provided, then
+    warning messages are logged there when a directory cannot be listed.
     """
     # Make sure the path isn't a Unicode string.
     path = bytestring_path(path)
 
     # Get all the directories and files at this level.
+    try:
+        contents = os.listdir(syspath(path))
+    except OSError as exc:
+        print('foo', logger, bool(logger))
+        if logger:
+            logger.warn(u'could not list directory {0}: {1}'.format(
+                displayable_path(path), exc.strerror
+            ))
+        return
     dirs = []
     files = []
-    for base in os.listdir(syspath(path)):
+    for base in contents:
         base = bytestring_path(base)
 
         # Skip ignored filenames.
     for base in dirs:
         cur = os.path.join(path, base)
         # yield from sorted_walk(...)
-        for res in sorted_walk(cur, ignore):
+        for res in sorted_walk(cur, ignore, logger):
             yield res
 
 def mkdirall(path):

beets/util/confit.py

         for appdir in self._search_dirs():
             filename = os.path.join(appdir, CONFIG_FILENAME)
             if os.path.isfile(filename):
-                yield ConfigSource(load_yaml(filename), filename)
+                yield ConfigSource(load_yaml(filename) or {}, filename)
 
     def _default_source(self):
         """Return the default-value source for this program or `None` if

beetsplug/convert.py

         dest = os.path.join(dest_dir, lib.destination(item, fragment=True))
         dest = os.path.splitext(dest)[0] + '.mp3'
 
-        if os.path.exists(dest):
+        if os.path.exists(util.syspath(dest)):
             log.info(u'Skipping {0} (target file exists)'.format(
                 util.displayable_path(item.path)
             ))

beetsplug/echonest_tempo.py

     """Get the tempo for a song."""
     # We must have sufficient metadata for the lookup. Otherwise the API
     # will just complain.
+    artist = artist.replace(u'\n', u' ').strip()
+    title = title.replace(u'\n', u' ').strip()
     if not artist or not title:
         return None
 
                 # Wait and try again.
                 time.sleep(RETRY_INTERVAL)
             else:
-                raise
+                log.warn(u'echonest_tempo: {0}'.format(e.args[0][0]))
+                return None
         except pyechonest.util.EchoNestIOError as e:
             log.debug(u'echonest_tempo: IO error: {0}'.format(e))
             time.sleep(RETRY_INTERVAL)

beetsplug/fetchart.py

                 break
 
     # Web art sources.
-    if not local_only and not out:
+    remote_priority = config['fetchart']['remote_priority'].get(bool)
+    if not local_only and (remote_priority or not out):
         for url in _source_urls(album):
             if maxwidth:
                 url = ArtResizer.shared.proxy_url(maxwidth, url)
         self.config.add({
             'auto': True,
             'maxwidth': 0,
+            'remote_priority': False,
         })
 
         # Holds paths to downloaded images between fetching them and

beetsplug/mbcollection.py

 
 SUBMISSION_CHUNK_SIZE = 200
 
+def mb_request(*args, **kwargs):
+    """Send a MusicBrainz API request and process exceptions.
+    """
+    try:
+        return musicbrainz._mb_request(*args, **kwargs)
+    except musicbrainzngs.AuthenticationError:
+        raise ui.UserError('authentication with MusicBrainz failed')
+    except musicbrainzngs.ResponseError as exc:
+        raise ui.UserError('MusicBrainz API error: {0}'.format(exc))
+    except musicbrainzngs.UsageError:
+        raise ui.UserError('MusicBrainz credentials missing')
+
 def submit_albums(collection_id, release_ids):
     """Add all of the release IDs to the indicated collection. Multiple
     requests are made if there are many release IDs to submit.
     for i in range(0, len(release_ids), SUBMISSION_CHUNK_SIZE):
         chunk = release_ids[i:i+SUBMISSION_CHUNK_SIZE]
         releaselist = ";".join(chunk)
-        musicbrainz._mb_request(
+        mb_request(
             "collection/%s/releases/%s" % (collection_id, releaselist),
-            'PUT', True, True, body='foo')
+            'PUT', True, True, body='foo'
+        )
         # A non-empty request body is required to avoid a 411 "Length
         # Required" error from the MB server.
 
 def update_collection(lib, opts, args):
     # Get the collection to modify.
-    collections = musicbrainz._mb_request('collection', 'GET', True, True)
+    collections = mb_request('collection', 'GET', True, True)
     if not collections['collection-list']:
         raise ui.UserError('no collections exist for user')
     collection_id = collections['collection-list'][0]['id']
         super(MusicBrainzCollectionPlugin, self).__init__()
         musicbrainzngs.auth(
             config['musicbrainz']['user'].get(unicode),
-            config['musicbrainz']['pass'].get(unicode)
+            config['musicbrainz']['pass'].get(unicode),
         )
 
     def commands(self):

beetsplug/smartplaylist.py

         # As we allow tags in the m3u names, we'll need to iterate through
         # the items and generate the correct m3u file names.
         for item in items:
-            m3u_name = item.evaluate_template(Template(basename), lib=lib)
+            m3u_name = item.evaluate_template(Template(basename), lib=lib,
+                sanitize=True)
             if not (m3u_name in m3us):
                 m3us[m3u_name] = []
             if relative_to:

docs/changelog.rst

 Changelog
 =========
 
-1.1b2 (in development)
+1.1b3 (in development)
 ----------------------
 
+New configuration options:
+
+* :ref:`languages` controls the preferred languages when selecting an alias
+  from MusicBrainz. This feature requires `python-musicbrainz-ngs`_ 0.3 or
+  later, which (at the time of this writing) is not yet released. Thanks to
+  Sam Doshi.
+* :ref:`detail` enables a mode where all tracks are listed in the importer UI,
+  as opposed to only changed tracks.
+* The ``--flat`` option to the ``beet import`` command treats an entire
+  directory tree of music files as a single album. This can help in situations
+  where a multi-disc album is split across multiple directories.
+
+Other stuff:
+
+* :doc:`/plugins/echonest_tempo`: API errors now issue a warning instead of
+  exiting with an exception. We also avoid an error when track metadata
+  contains newlines.
+* When the importer encounters an error (insufficient permissions, for
+  example) when walking a directory tree, it now logs an error instead of
+  crashing.
+* In path formats, null database values now expand to the empty string instead
+  of the string "None".
+* Add "System Volume Information" (an internal directory found on some
+  Windows filesystems) to the default ignore list.
+* Fix a crash when ReplayGain values were set to null.
+* Fix a crash when iTunes Sound Check tags contained invalid data.
+* Fix an error when the configuration file (``config.yaml``) is completely
+  empty.
+* Fix an error introduced in 1.1b1 when importing using timid mode. Thanks to
+  Sam Doshi.
+* :doc:`/plugins/convert`: Fix a bug when creating files with Unicode
+  pathnames.
+* Fix a spurious warning from the Unidecode module when matching albums that
+  are missing all metadata.
+* :doc:`/plugins/mbcollection`: Show friendly, human-readable errors when
+  MusicBrainz exceptions occur.
+
+1.1b2 (February 16, 2013)
+-------------------------
+
+The second beta of beets 1.1 uses the fancy new configuration infrastructure to
+add many, many new config options. The import process is more flexible;
+filenames can be customized in more detail; and more. This release also
+supports Windows Media (ASF) files and iTunes Sound Check volume normalization.
+
 This version introduces one **change to the default behavior** that you should
 be aware of. Previously, when importing new albums matched in MusicBrainz, the
 date fields (``year``, ``month``, and ``day``) would be set to the release date
 * :ref:`max_filename_length` controls truncation of long filenames. Also, beets
   now tries to determine the filesystem's maximum length automatically if you
   leave this option unset.
+* :doc:`/plugins/fetchart`: The ``remote_priority`` option searches remote
+  (Web) art sources even when local art is present.
 * You can now customize the character substituted for path separators (e.g., /)
   in filenames via ``path_sep_replace``. The default is an underscore. Use this
   setting with caution.
 * Fix an error when migrating the ``.beetsstate`` file on Windows.
 * A nicer error message is now given when the configuration file contains tabs.
   (YAML doesn't like tabs.)
+* Fix the ``-l`` (log path) command-line option for the ``import`` command.
 
 .. _iTunes Sound Check: http://support.apple.com/kb/HT2425
 
 copyright = u'2012, Adrian Sampson'
 
 version = '1.1'
-release = '1.1b2'
+release = '1.1b3'
 
 pygments_style = 'sphinx'
 

docs/guides/main.rst

   ``emerge beets`` to install. There are several USE flags available for
   optional plugin dependencies.
 
+* On **FreeBSD**, there's a `beets port`_ at ``audio/beets``.
+
+.. _beets port: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets
 .. _beets from AUR: http://aur.archlinux.org/packages.php?ID=39577
 .. _dev package: http://aur.archlinux.org/packages.php?ID=48617
 .. _Debian details: http://packages.qa.debian.org/b/beets.html
 .. _Ubuntu details: https://launchpad.net/ubuntu/+source/beets
-.. _One by vh4x0r: https://github.com/vh4x0r/apokolips
-.. _one by syranez: https://github.com/syranez/gentoo
 
 If you have `pip`_, just say ``pip install beets`` (you might need ``sudo`` in
 front of that). On Arch, you'll need to use ``pip2`` instead of ``pip``.
 configuration is stored in a text file: on Unix-like OSes, the config file is
 at ``~/.config/beets/config.yaml``; on Windows, it's at
 ``%APPDATA%\beets\config.yaml``. Create and edit the appropriate file with your
-favorite text editor. This file will start out empty, but here's good place to
-start::
+favorite text editor. (You may need to create the enclosing directories also.)
+The file will start out empty, but here's good place to start::
 
     directory: ~/music
     library: ~/data/musiclibrary.blb

docs/guides/tagger.rst

 If you think beets is ignoring an album that's listed in MusicBrainz, please
 `file a bug report`_.
 
-.. _file a bug report: http://code.google.com/p/beets/issues/entry
+.. _file a bug report: https://github.com/sampsyo/beets/issues
 
 I Hope That Makes Sense
 -----------------------
 
 .. _beets: http://beets.radbox.org/
 .. _email the author: mailto:adrian@radbox.org
-.. _file a bug: http://code.google.com/p/beets/issues/entry
+.. _file a bug: https://github.com/sampsyo/beets/issues
 
 Contents
 --------

docs/plugins/bpd.rst

 with its Python bindings) on your system.
 
 * On Mac OS X, you can use `MacPorts`_ or `Homebrew`_. For MacPorts, just run
-  ``port install py27-gst-python``. For Homebrew, use `my auxiliary repository`_
-  to install: ``brew tap sampsyo/py ; brew install gst-python``.
-  (Note that you'll need the Mac OS X Developer Tools in either case.)
+  ``port install py27-gst-python``. For Homebrew, use ``brew install
+  gst-python``. (Note that you'll need the Mac OS X Developer Tools in either
+  case.)
 
 * On Linux, it's likely that you already have gst-python. (If not, your
   distribution almost certainly has a package for it.)
 * On Windows, you may want to try `GStreamer WinBuilds`_ (cavet emptor: I
   haven't tried this).
 
+You will also need the various GStreamer plugin packages to make everything
+work. See the :doc:`/plugins/chroma` documentation for more information on
+installing GStreamer plugins.
+
 .. _MacPorts: http://www.macports.org/
 .. _GStreamer WinBuilds: http://www.gstreamer-winbuild.ylatuya.es/
 .. _Homebrew: http://mxcl.github.com/homebrew/
-.. _my auxiliary repository: https://github.com/sampsyo/homebrew-py
 
 Using and Configuring
 ---------------------

docs/plugins/fetchart.rst

 for album art. For "as-is" imports (and non-autotagged imports using the ``-A``
 flag), beets only looks for art on the local filesystem.
 
+By default, remote (Web) art sources are only queried if no local art is found
+in the filesystem. To query remote sources every time, set the
+``remote_priority`` configuration option to false.
+
 Embedding Album Art
 -------------------
 

docs/plugins/writing.rst

     from beets.ui import Subcommand
 
     my_super_command = Subcommand('super', help='do something super')
-    def say_hi(lib, config, opts, args):
+    def say_hi(lib, opts, args):
         print "Hello everybody! I'm a plugin!"
     my_super_command.func = say_hi
 
 beets ``Library`` object) and ``opts`` and ``args`` (command-line options and
 arguments as returned by `OptionParser.parse_args`_).
 
-.. _ConfigParser object: http://docs.python.org/library/configparser.html
 .. _OptionParser.parse_args:
     http://docs.python.org/library/optparse.html#parsing-arguments
 
 
 * *album_imported*: called with an ``Album`` object every time the ``import``
   command finishes adding an album to the library. Parameters: ``lib``,
-  ``album``, ``config``
+  ``album``
 
 * *item_imported*: called with an ``Item`` object every time the importer adds a
   singleton to the library (not called for full-album imports). Parameters:
-  ``lib``, ``item``, ``config``
+  ``lib``, ``item``
 
 * *write*: called with an ``Item`` object just before a file's metadata is
   written to disk (i.e., just before the file on disk is opened).
 
 * *import_task_start*: called when before an import task begins processing.
-  Parameters: ``task`` and ``config``.
+  Parameters: ``task`` (an `ImportTask`) and ``session`` (an `ImportSession`).
 
 * *import_task_apply*: called after metadata changes have been applied in an
-  import task. Parameters: ``task`` and ``config``.
+  import task. Parameters: ``task`` and ``session``.
 
 * *import_task_choice*: called after a decision has been made about an import
   task. This event can be used to initiate further interaction with the user.
   Use ``task.choice_flag`` to determine the action to be taken. Parameters:
-  ``task`` and ``config``.
+  ``task`` and ``session``.
 
 * *import_task_files*: called after an import task finishes manipulating the
   filesystem (copying and moving files, writing metadata tags). Parameters:
-  ``task`` and ``config``.
+  ``task`` and ``session``.
 
 * *library_opened*: called after beets starts up and initializes the main
   Library object. Parameter: ``lib``.
 * *database_change*: a modification has been made to the library database. The
   change might not be committed yet. Parameter: ``lib``.
 
-* *cli_exit*: called just before the ``beet`` command-line program exits. Parameter: ``lib``.
+* *cli_exit*: called just before the ``beet`` command-line program exits.
+  Parameter: ``lib``.
 
 The included ``mpdupdate`` plugin provides an example use case for event listeners.
 

docs/reference/cli.rst

   instead want to import individual, non-album tracks, use the *singleton*
   mode by supplying the ``-s`` option.
 
+* If you have an album that's split across several directories under a common
+  top directory, use the ``--flat`` option. This takes all the music files
+  under the directory (recursively) and treats them as a single large album
+  instead of as one album per directory. This can help with your more stubborn
+  multi-disc albums.
+
 .. only:: html
 
     Reimporting

docs/reference/config.rst

 ignore
 ~~~~~~
 
-A space-separated list of glob patterns specifying file and directory names
-to be ignored when importing. Defaults to ``.* *~`` (i.e., ignore
-Unix-style hidden files and backup files).
+A list of glob patterns specifying file and directory names to be ignored when
+importing. By default, this consists of ``.*``,  ``*~``, and ``System Volume
+Information`` (i.e., beets ignores Unix-style hidden files, backup files, and
+a directory that appears at the root of some Windows filesystems).
 
 .. _replace:
 
 action that will be taken when you type return without an option letter. The
 default is ``apply``.
 
+.. _languages:
+
+languages
+~~~~~~~~~
+
+A list of locale names to search for preferred aliases. For example, setting
+this to "en" uses the transliterated artist name "Pyotr Ilyich Tchaikovsky"
+instead of the Cyrillic script for the composer's name when tagging from
+MusicBrainz. Defaults to an empty list, meaning that no language is preferred.
+
+.. _detail:
+
+detail
+~~~~~~
+
+Whether the importer UI should show detailed information about each match it
+finds. When enabled, this mode prints out the title of every track, regardless
+of whether it matches the original metadata. (The default behavior only shows
+changes.) Default: ``no``.
+
 .. _musicbrainz-config:
 
 MusicBrainz Options
     shutil.copytree(os.path.join(docdir, '_build', 'man'), mandir)
 
 setup(name='beets',
-      version='1.1.0-beta.2',
+      version='1.1.0-beta.3',
       description='music tagger and library organizer',
       author='Adrian Sampson',
       author_email='adrian@radbox.org',
       },
 
       install_requires=[
-          'mutagen',
+          'mutagen>=1.20',
           'munkres',
           'unidecode',
-          'musicbrainzngs',
+          'musicbrainzngs>=0.2',
           'pyyaml',
       ]
       + (['colorama'] if (sys.platform == 'win32') else [])
 import sys
 import os
 import logging
-import contextlib
-import copy
+import tempfile
+import shutil
 
 # Use unittest2 on Python < 2.7.
 try:
     cls = commands.TerminalImportSession if cli else importer.ImportSession
     return cls(lib, logfile, paths, query)
 
-# Temporary config modifications.
+# A test harness for all beets tests.
+# Provides temporary, isolated configuration.
 class TestCase(unittest.TestCase):
     """A unittest.TestCase subclass that saves and restores beets'
     global configuration. This allows tests to make temporary
     modifications that will then be automatically removed when the test
-    completes. Also provides some additional assertion methods.
+    completes. Also provides some additional assertion methods, a
+    temporary directory, and a DummyIO.
     """
     def setUp(self):
         # A "clean" source list including only the defaults.
         beets.config.sources = []
         beets.config.read(user=False, defaults=True)
 
+        # Direct paths to a temporary directory. Tests can also use this
+        # temporary directory.
+        self.temp_dir = tempfile.mkdtemp()
+        beets.config['statefile'] = os.path.join(self.temp_dir, 'state.pickle')
+        beets.config['library'] = os.path.join(self.temp_dir, 'library.db')
+        beets.config['directory'] = os.path.join(self.temp_dir, 'libdir')
+
+        # Set $HOME, which is used by confit's `config_dir()` to create
+        # directories.
+        self._old_home = os.environ.get('HOME')
+        os.environ['HOME'] = self.temp_dir
+
+        # Initialize, but don't install, a DummyIO.
+        self.io = DummyIO()
+
     def tearDown(self):
-        pass
+        if os.path.isdir(self.temp_dir):
+            shutil.rmtree(self.temp_dir)
+        os.environ['HOME'] = self._old_home
+        self.io.restore()
 
     def assertExists(self, path):
         self.assertTrue(os.path.exists(path),
         artpath = fetchart._fetch_image('http://example.com')
         self.assertNotEqual(artpath, None)
 
-class FSArtTest(unittest.TestCase):
+class FSArtTest(_common.TestCase):
     def setUp(self):
-        self.dpath = os.path.join(_common.RSRC, 'arttest')
+        super(FSArtTest, self).setUp()
+        self.dpath = os.path.join(self.temp_dir, 'arttest')
         os.mkdir(self.dpath)
-    def tearDown(self):
-        shutil.rmtree(self.dpath)
 
     def test_finds_jpg_in_directory(self):
         _common.touch(os.path.join(self.dpath, 'a.jpg'))
         fn = fetchart.art_in_path(self.dpath)
         self.assertEqual(fn, None)
 
-class CombinedTest(unittest.TestCase):
+class CombinedTest(_common.TestCase):
     def setUp(self):
-        self.dpath = os.path.join(_common.RSRC, 'arttest')
+        super(CombinedTest, self).setUp()
+
+        self.dpath = os.path.join(self.temp_dir, 'arttest')
         os.mkdir(self.dpath)
         self.old_urlopen = fetchart.urllib.urlopen
         fetchart.urllib.urlopen = self._urlopen
         self.page_text = ""
         self.urlopen_called = False
+
+        # Set up configuration.
+        fetchart.FetchArtPlugin()
+
     def tearDown(self):
-        shutil.rmtree(self.dpath)
+        super(CombinedTest, self).tearDown()
         fetchart.urllib.urlopen = self.old_urlopen
 
     def _urlopen(self, url):
         super(ArtImporterTest, self).setUp()
 
         # Mock the album art fetcher to always return our test file.
-        self.art_file = os.path.join(_common.RSRC, 'tmpcover.jpg')
+        self.art_file = os.path.join(self.temp_dir, 'tmpcover.jpg')
         _common.touch(self.art_file)
         self.old_afa = fetchart.art_for_album
         self.afa_response = self.art_file
         fetchart.art_for_album = art_for_album
 
         # Test library.
-        self.libpath = os.path.join(_common.RSRC, 'tmplib.blb')
-        self.libdir = os.path.join(_common.RSRC, 'tmplib')
+        self.libpath = os.path.join(self.temp_dir, 'tmplib.blb')
+        self.libdir = os.path.join(self.temp_dir, 'tmplib')
         os.mkdir(self.libdir)
         os.mkdir(os.path.join(self.libdir, 'album'))
         itempath = os.path.join(self.libdir, 'album', 'test.mp3')
 
     def tearDown(self):
         super(ArtImporterTest, self).tearDown()
-
         fetchart.art_for_album = self.old_afa
-        if os.path.exists(self.art_file):
-            os.remove(self.art_file)
-        if os.path.exists(self.libpath):
-            os.remove(self.libpath)
-        if os.path.exists(self.libdir):
-            shutil.rmtree(self.libdir)
 
     def _fetch_art(self, should_exist):
         """Execute the fetch_art coroutine for the task and return the

test/test_autotag.py

 
 def _mkmp3(path):
     shutil.copyfile(os.path.join(_common.RSRC, 'min.mp3'), path)
-class AlbumsInDirTest(unittest.TestCase):
+class AlbumsInDirTest(_common.TestCase):
     def setUp(self):
+        super(AlbumsInDirTest, self).setUp()
+
         # create a directory structure for testing
-        self.base = os.path.abspath(os.path.join(_common.RSRC, 'tempdir'))
+        self.base = os.path.abspath(os.path.join(self.temp_dir, 'tempdir'))
         os.mkdir(self.base)
 
         os.mkdir(os.path.join(self.base, 'album1'))
         _mkmp3(os.path.join(self.base, 'album2', 'album2song.mp3'))
         _mkmp3(os.path.join(self.base, 'more', 'album3', 'album3song.mp3'))
         _mkmp3(os.path.join(self.base, 'more', 'album4', 'album4song.mp3'))
-    def tearDown(self):
-        shutil.rmtree(self.base)
 
     def test_finds_all_albums(self):
         albums = list(autotag.albums_in_dir(self.base))
             else:
                 self.assertEqual(len(album), 1)
 
-class MultiDiscAlbumsInDirTest(unittest.TestCase):
+class MultiDiscAlbumsInDirTest(_common.TestCase):
     def setUp(self):
-        self.base = os.path.abspath(os.path.join(_common.RSRC, 'tempdir'))
+        super(MultiDiscAlbumsInDirTest, self).setUp()
+
+        self.base = os.path.abspath(os.path.join(self.temp_dir, 'tempdir'))
         os.mkdir(self.base)
 
         self.dirs = [
         for path in self.files:
             _mkmp3(path)
 
-    def tearDown(self):
-        shutil.rmtree(self.base)
-
     def test_coalesce_nested_album_multiple_subdirs(self):
         albums = list(autotag.albums_in_dir(self.base))
         self.assertEquals(len(albums), 4)
         val = beets.library.format_for_path(12345, 'samplerate', posixpath)
         self.assertEqual(val, u'12kHz')
 
+    def test_component_sanitize_none(self):
+        val = beets.library.format_for_path(None, 'foo', posixpath)
+        self.assertEqual(val, u'')
+
     def test_artist_falls_back_to_albumartist(self):
         self.i.artist = ''
         self.i.albumartist = 'something'

test/test_files.py

 
 class MoveTest(_common.TestCase):
     def setUp(self):
+        super(MoveTest, self).setUp()
+
         # make a temporary file
-        self.path = join(_common.RSRC, 'temp.mp3')
+        self.path = join(self.temp_dir, 'temp.mp3')
         shutil.copy(join(_common.RSRC, 'full.mp3'), self.path)
 
         # add it to a temporary library
         self.lib.add(self.i)
 
         # set up the destination
-        self.libdir = join(_common.RSRC, 'testlibdir')
+        self.libdir = join(self.temp_dir, 'testlibdir')
+        os.mkdir(self.libdir)
         self.lib.directory = self.libdir
         self.lib.path_formats = [('default',
                                   join('$artist', '$album', '$title'))]
         self.i.title = 'three'
         self.dest = join(self.libdir, 'one', 'two', 'three.mp3')
 
-        self.otherdir = join(_common.RSRC, 'testotherdir')
-
-    def tearDown(self):
-        if os.path.exists(self.path):
-            os.remove(self.path)
-        if os.path.exists(self.libdir):
-            shutil.rmtree(self.libdir)
-        if os.path.exists(self.otherdir):
-            shutil.rmtree(self.otherdir)
+        self.otherdir = join(self.temp_dir, 'testotherdir')
 
     def test_move_arrives(self):
         self.lib.move(self.i)
 
 class AlbumFileTest(_common.TestCase):
     def setUp(self):
+        super(AlbumFileTest, self).setUp()
+
         # Make library and item.
         self.lib = beets.library.Library(':memory:')
         self.lib.path_formats = \
             [('default', join('$albumartist', '$album', '$title'))]
-        self.libdir = os.path.join(_common.RSRC, 'testlibdir')
+        self.libdir = os.path.join(self.temp_dir, 'testlibdir')
         self.lib.directory = self.libdir
         self.i = item()
         # Make a file for the item.
         # Make an album.
         self.ai = self.lib.add_album((self.i,))
         # Alternate destination dir.
-        self.otherdir = os.path.join(_common.RSRC, 'testotherdir')
-    def tearDown(self):
-        if os.path.exists(self.libdir):
-            shutil.rmtree(self.libdir)
-        if os.path.exists(self.otherdir):
-            shutil.rmtree(self.otherdir)
+        self.otherdir = os.path.join(self.temp_dir, 'testotherdir')
 
     def test_albuminfo_move_changes_paths(self):
         self.ai.album = 'newAlbumName'
 
 class ArtFileTest(_common.TestCase):
     def setUp(self):
+        super(ArtFileTest, self).setUp()
+
         # Make library and item.
         self.lib = beets.library.Library(':memory:')
-        self.libdir = os.path.abspath(os.path.join(_common.RSRC, 'testlibdir'))
+        self.libdir = os.path.abspath(os.path.join(self.temp_dir, 'testlibdir'))
         self.lib.directory = self.libdir
         self.i = item()
         self.i.path = self.lib.destination(self.i)
         touch(self.art)
         self.ai.artpath = self.art
         # Alternate destination dir.
-        self.otherdir = os.path.join(_common.RSRC, 'testotherdir')
-    def tearDown(self):
-        if os.path.exists(self.libdir):
-            shutil.rmtree(self.libdir)
-        if os.path.exists(self.otherdir):
-            shutil.rmtree(self.otherdir)
+        self.otherdir = os.path.join(self.temp_dir, 'testotherdir')
 
     def test_art_deleted_when_items_deleted(self):
         self.assertTrue(os.path.exists(self.art))
 
 class RemoveTest(_common.TestCase):
     def setUp(self):
+        super(RemoveTest, self).setUp()
+
         # Make library and item.
         self.lib = beets.library.Library(':memory:')
-        self.libdir = os.path.abspath(os.path.join(_common.RSRC, 'testlibdir'))
+        self.libdir = os.path.abspath(os.path.join(self.temp_dir, 'testlibdir'))
         self.lib.directory = self.libdir
         self.i = item()
         self.i.path = self.lib.destination(self.i)
         touch(self.i.path)
         # Make an album with the item.
         self.ai = self.lib.add_album((self.i,))
-    def tearDown(self):
-        if os.path.exists(self.libdir):
-            shutil.rmtree(self.libdir)
 
     def test_removing_last_item_prunes_empty_dir(self):
         parent = os.path.dirname(self.i.path)
         self.assertExists(self.libdir)
 
     def test_removing_item_outside_of_library_deletes_nothing(self):
-        self.lib.directory = os.path.abspath(os.path.join(_common.RSRC, 'xxx'))
+        self.lib.directory = os.path.abspath(os.path.join(self.temp_dir, 'xxx'))
         parent = os.path.dirname(self.i.path)
         self.lib.remove(self.i, True)
         self.assertExists(parent)
 
     def test_removing_last_item_in_album_with_albumart_prunes_dir(self):
-        artfile = os.path.join(_common.RSRC, 'testart.jpg')
+        artfile = os.path.join(self.temp_dir, 'testart.jpg')
         touch(artfile)
         self.ai.set_art(artfile)
-        os.remove(artfile)
 
         parent = os.path.dirname(self.i.path)
         self.lib.remove(self.i, True)
 # Tests that we can "delete" nonexistent files.
 class SoftRemoveTest(_common.TestCase):
     def setUp(self):
-        self.path = os.path.join(_common.RSRC, 'testfile')
+        super(SoftRemoveTest, self).setUp()
+
+        self.path = os.path.join(self.temp_dir, 'testfile')
         touch(self.path)
-    def tearDown(self):
-        if os.path.exists(self.path):
-            os.remove(self.path)
 
     def test_soft_remove_deletes_file(self):
         util.remove(self.path, True)
 
 class SafeMoveCopyTest(_common.TestCase):
     def setUp(self):
-        self.path = os.path.join(_common.RSRC, 'testfile')
+        super(SafeMoveCopyTest, self).setUp()
+
+        self.path = os.path.join(self.temp_dir, 'testfile')
         touch(self.path)
-        self.otherpath = os.path.join(_common.RSRC, 'testfile2')
+        self.otherpath = os.path.join(self.temp_dir, 'testfile2')
         touch(self.otherpath)
         self.dest = self.path + '.dest'
-    def tearDown(self):
-        if os.path.exists(self.path):
-            os.remove(self.path)
-        if os.path.exists(self.otherpath):
-            os.remove(self.otherpath)
-        if os.path.exists(self.dest):
-            os.remove(self.dest)
 
     def test_successful_move(self):
         util.move(self.path, self.dest)
 
 class PruneTest(_common.TestCase):
     def setUp(self):
-        self.base = os.path.join(_common.RSRC, 'testdir')
+        super(PruneTest, self).setUp()
+
+        self.base = os.path.join(self.temp_dir, 'testdir')
         os.mkdir(self.base)
         self.sub = os.path.join(self.base, 'subdir')
         os.mkdir(self.sub)
-    def tearDown(self):
-        if os.path.exists(self.base):
-            shutil.rmtree(self.base)
 
     def test_prune_existent_directory(self):
         util.prune_dirs(self.sub, self.base)
 
 class WalkTest(_common.TestCase):
     def setUp(self):
-        self.base = os.path.join(_common.RSRC, 'testdir')
+        super(WalkTest, self).setUp()
+
+        self.base = os.path.join(self.temp_dir, 'testdir')
         os.mkdir(self.base)
         touch(os.path.join(self.base, 'y'))
         touch(os.path.join(self.base, 'x'))
         os.mkdir(os.path.join(self.base, 'd'))
         touch(os.path.join(self.base, 'd', 'z'))
-    def tearDown(self):
-        if os.path.exists(self.base):
-            shutil.rmtree(self.base)
 
     def test_sorted_files(self):
         res = list(util.sorted_walk(self.base))
 
 class UniquePathTest(_common.TestCase):
     def setUp(self):
-        self.base = os.path.join(_common.RSRC, 'testdir')
+        super(UniquePathTest, self).setUp()
+
+        self.base = os.path.join(self.temp_dir, 'testdir')
         os.mkdir(self.base)
         touch(os.path.join(self.base, 'x.mp3'))
         touch(os.path.join(self.base, 'x.1.mp3'))
         touch(os.path.join(self.base, 'x.2.mp3'))
         touch(os.path.join(self.base, 'y.mp3'))
-    def tearDown(self):
-        if os.path.exists(self.base):
-            shutil.rmtree(self.base)
 
     def test_new_file_unchanged(self):
         path = util.unique_path(os.path.join(self.base, 'z.mp3'))

test/test_importer.py

     def setUp(self):
         super(NonAutotaggedImportTest, self).setUp()
 
-        self.io = _common.DummyIO()
         self.io.install()
 
-        self.libdb = os.path.join(_common.RSRC, 'testlib.blb')
+        self.libdb = os.path.join(self.temp_dir, 'testlib.blb')
         self.lib = library.Library(self.libdb)
-        self.libdir = os.path.join(_common.RSRC, 'testlibdir')
+        self.libdir = os.path.join(self.temp_dir, 'testlibdir')
         self.lib.directory = self.libdir
         self.lib.path_formats = [(
             'default', os.path.join('$artist', '$album', '$title')
         )]
 
-        self.srcdir = os.path.join(_common.RSRC, 'testsrcdir')
-
-    def tearDown(self):
-        super(NonAutotaggedImportTest, self).tearDown()
-
-        self.io.restore()
-        if os.path.exists(self.libdb):
-            os.remove(self.libdb)
-        if os.path.exists(self.libdir):
-            shutil.rmtree(self.libdir)
-        if os.path.exists(self.srcdir):
-            shutil.rmtree(self.srcdir)
+        self.srcdir = os.path.join(self.temp_dir, 'testsrcdir')
 
     def _create_test_file(self, filepath, metadata):
         """Creates an mp3 file at the given path within self.srcdir.
     def setUp(self):
         super(ImportApplyTest, self).setUp()
 
-        self.libdir = os.path.join(_common.RSRC, 'testlibdir')
+        self.libdir = os.path.join(self.temp_dir, 'testlibdir')
         os.mkdir(self.libdir)
-        self.libpath = os.path.join(_common.RSRC, 'testlib.blb')
+        self.libpath = os.path.join(self.temp_dir, 'testlib.blb')
         self.lib = library.Library(self.libpath, self.libdir)
         self.lib.path_formats = [
             ('default', 'one'),
         ]
         self.session = _common.import_session(self.lib)
 
-        self.srcdir = os.path.join(_common.RSRC, 'testsrcdir')
+        self.srcdir = os.path.join(self.temp_dir, 'testsrcdir')
         os.mkdir(self.srcdir)
         os.mkdir(os.path.join(self.srcdir, 'testalbum'))
         self.srcpath = os.path.join(self.srcdir, 'testalbum', 'srcfile.mp3')
             albumtype = 'soundtrack',
         )
 
-    def tearDown(self):
-        super(ImportApplyTest, self).tearDown()
-
-        shutil.rmtree(self.libdir)
-        if os.path.exists(self.srcdir):
-            shutil.rmtree(self.srcdir)
-        if os.path.exists(self.libpath):
-            os.unlink(self.libpath)
-
     def test_finalize_no_delete(self):
         config['import']['delete'] = False
         _call_stages(self.session, [self.i], self.info)
 
 class AsIsApplyTest(_common.TestCase):
     def setUp(self):
-        self.dbpath = os.path.join(_common.RSRC, 'templib.blb')
+        super(AsIsApplyTest, self).setUp()
+
+        self.dbpath = os.path.join(self.temp_dir, 'templib.blb')
         self.lib = library.Library(self.dbpath)
         self.session = _common.import_session(self.lib)
 
         i1.albumartist = i2.albumartist = i3.albumartist = ''
         self.items = [i1, i2, i3]
 
-    def tearDown(self):
-        os.remove(self.dbpath)
-
     def _apply_result(self):
         """Run the "apply" coroutines and get the resulting Album."""
         _call_stages(self.session, self.items, importer.action.ASIS,
     def setUp(self):
         super(ApplyExistingItemsTest, self).setUp()
 
-        self.libdir = os.path.join(_common.RSRC, 'testlibdir')
+        self.libdir = os.path.join(self.temp_dir, 'testlibdir')
         os.mkdir(self.libdir)
 
-        self.dbpath = os.path.join(_common.RSRC, 'templib.blb')
+        self.dbpath = os.path.join(self.temp_dir, 'templib.blb')
         self.lib = library.Library(self.dbpath, self.libdir)
         self.lib.path_formats = [
             ('default', '$artist/$title'),
         self.i = library.Item.from_path(self.srcpath)
         self.i.comp = False
 
-    def tearDown(self):
-        super(ApplyExistingItemsTest, self).tearDown()
-
-        os.remove(self.dbpath)
-        shutil.rmtree(self.libdir)
-
     def _apply_asis(self, items, album=True):
         """Run the "apply" coroutine."""
         _call_stages(self.session, items, importer.action.ASIS, album=album,
 """
 from _common import unittest
 from beets.autotag import mb
+from beets import config
 
 class MBAlbumInfoTest(unittest.TestCase):
     def _make_release(self, date_str='2009', tracks=None):
             'name': 'CREDIT' + suffix,
         }
 
+    def _add_alias(self, credit_dict, suffix='', locale='', primary=False):
+        alias = {
+            'alias': 'ALIAS' + suffix,
+            'locale': locale,
+            'sort-name': 'ALIASSORT' + suffix
+        }
+        if primary:
+            alias['primary'] = 'primary'
+        if 'alias-list' not in credit_dict['artist']:
+            credit_dict['artist']['alias-list'] = []
+        credit_dict['artist']['alias-list'].append(alias)
+
     def test_single_artist(self):
         a, s, c = mb._flatten_artist_credit([self._credit_dict()])
         self.assertEqual(a, 'NAME')
         self.assertEqual(s, 'SORTa AND SORTb')
         self.assertEqual(c, 'CREDITa AND CREDITb')
 
+    def test_alias(self):
+        credit_dict = self._credit_dict()
+        self._add_alias(credit_dict, suffix='en', locale='en')
+        self._add_alias(credit_dict, suffix='en_GB', locale='en_GB')
+        self._add_alias(credit_dict, suffix='fr', locale='fr')
+        self._add_alias(credit_dict, suffix='fr_P', locale='fr', primary=True)
+
+        # test no alias
+        config['import']['languages'] = ['']
+        flat = mb._flatten_artist_credit([credit_dict])
+        self.assertEqual(flat, ('NAME', 'SORT', 'CREDIT'))
+
+        # test en
+        config['import']['languages'] = ['en']
+        flat = mb._flatten_artist_credit([credit_dict])
+        self.assertEqual(flat, ('ALIASen', 'ALIASSORTen', 'CREDIT'))
+
+        # test en_GB en
+        config['import']['languages'] = ['en_GB', 'en']
+        flat = mb._flatten_artist_credit([credit_dict])
+        self.assertEqual(flat, ('ALIASen_GB', 'ALIASSORTen_GB', 'CREDIT'))
+
+        # test en en_GB
+        config['import']['languages'] = ['en', 'en_GB']
+        flat = mb._flatten_artist_credit([credit_dict])
+        self.assertEqual(flat, ('ALIASen', 'ALIASSORTen', 'CREDIT'))
+
+        # test fr primary
+        config['import']['languages'] = ['fr']
+        flat = mb._flatten_artist_credit([credit_dict])
+        self.assertEqual(flat, ('ALIASfr_P', 'ALIASSORTfr_P', 'CREDIT'))
+
 def suite():
     return unittest.TestLoader().loadTestsFromName(__name__)
 

test/test_mediafile.py

         return 0.0
 class MissingAudioDataTest(unittest.TestCase):
     def setUp(self):
+        super(MissingAudioDataTest, self).setUp()
         path = os.path.join(_common.RSRC, 'full.mp3')
         self.mf = ZeroLengthMediaFile(path)
 
 
 class TypeTest(unittest.TestCase):
     def setUp(self):
+        super(TypeTest, self).setUp()
         path = os.path.join(_common.RSRC, 'full.mp3')
         self.mf = beets.mediafile.MediaFile(path)
 
         self.mf.year = '2009'
         self.assertEqual(self.mf.year, 2009)
 
+    def test_set_replaygain_gain_to_none(self):
+        self.mf.rg_track_gain = None
+        self.assertEqual(self.mf.rg_track_gain, 0.0)
+
+    def test_set_replaygain_peak_to_none(self):
+        self.mf.rg_track_peak = None
+        self.assertEqual(self.mf.rg_track_peak, 0.0)
+
+    def test_set_year_to_none(self):
+        self.mf.year = None
+        self.assertEqual(self.mf.year, 0)
+
+    def test_set_track_to_none(self):
+        self.mf.track = None
+        self.assertEqual(self.mf.track, 0)
+
+class SoundCheckTest(unittest.TestCase):
+    def test_round_trip(self):
+        data = beets.mediafile._sc_encode(1.0, 1.0)
+        gain, peak = beets.mediafile._sc_decode(data)
+        self.assertEqual(gain, 1.0)
+        self.assertEqual(peak, 1.0)
+
+    def test_decode_zero(self):
+        data = u' 80000000 80000000 00000000 00000000 00000000 00000000 ' \
+               u'00000000 00000000 00000000 00000000'
+        gain, peak = beets.mediafile._sc_decode(data)
+        self.assertEqual(gain, 0.0)
+        self.assertEqual(peak, 0.0)
+
+    def test_malformatted(self):
+        gain, peak = beets.mediafile._sc_decode(u'foo')
+        self.assertEqual(gain, 0.0)
+        self.assertEqual(peak, 0.0)
+
 def suite():
     return unittest.TestLoader().loadTestsFromName(__name__)
 
 
 class ListTest(_common.TestCase):
     def setUp(self):
-        self.io = _common.DummyIO()
+        super(ListTest, self).setUp()
         self.io.install()
 
         self.lib = library.Library(':memory:')
         self.lib.add_album([i])
         self.item = i
 
-    def tearDown(self):
-        self.io.restore()
-
     def _run_list(self, query='', album=False, path=False, fmt=None):
         commands.list_items(self.lib, query, album, fmt)
 
 
 class RemoveTest(_common.TestCase):
     def setUp(self):
-        self.io = _common.DummyIO()
+        super(RemoveTest, self).setUp()
+
         self.io.install()
 
-        self.libdir = os.path.join(_common.RSRC, 'testlibdir')
+        self.libdir = os.path.join(self.temp_dir, 'testlibdir')
         os.mkdir(self.libdir)
 
         # Copy a file into the library.
         self.i = library.Item.from_path(os.path.join(_common.RSRC, 'full.mp3'))
         self.lib.add(self.i, True)
 
-    def tearDown(self):
-        self.io.restore()
-        shutil.rmtree(self.libdir)
-
     def test_remove_items_no_delete(self):
         self.io.addinput('y')
         commands.remove_items(self.lib, '', False, False)
 
 class ModifyTest(_common.TestCase):
     def setUp(self):
-        self.io = _common.DummyIO()
+        super(ModifyTest, self).setUp()
+
         self.io.install()
 
-        self.libdir = os.path.join(_common.RSRC, 'testlibdir')
-        os.mkdir(self.libdir)
+        self.libdir = os.path.join(self.temp_dir, 'testlibdir')
 
         # Copy a file into the library.
         self.lib = library.Library(':memory:', self.libdir)
         self.lib.add(self.i, True)
         self.album = self.lib.add_album([self.i])
 
-    def tearDown(self):
-        self.io.restore()
-        shutil.rmtree(self.libdir)
-
     def _modify(self, mods, query=(), write=False, move=False, album=False):
         self.io.addinput('y')
         commands.modify_items(self.lib, mods, query,
 
 class MoveTest(_common.TestCase):
     def setUp(self):
-        self.io = _common.DummyIO()
+        super(MoveTest, self).setUp()
+
         self.io.install()
 
-        self.libdir = os.path.join(_common.RSRC, 'testlibdir')
+        self.libdir = os.path.join(self.temp_dir, 'testlibdir')
         os.mkdir(self.libdir)
 
         self.itempath = os.path.join(self.libdir, 'srcfile')
         self.album = self.lib.add_album([self.i])
 
         # Alternate destination directory.
-        self.otherdir = os.path.join(_common.RSRC, 'testotherdir')
-
-    def tearDown(self):
-        self.io.restore()
-        shutil.rmtree(self.libdir)
-        if os.path.exists(self.otherdir):
-            shutil.rmtree(self.otherdir)
+        self.otherdir = os.path.join(self.temp_dir, 'testotherdir')
 
     def _move(self, query=(), dest=None, copy=False, album=False):
         commands.move_items(self.lib, dest, query, copy, album)
 
 class UpdateTest(_common.TestCase):
     def setUp(self):
-        self.io = _common.DummyIO()
+        super(UpdateTest, self).setUp()
+
         self.io.install()
 
-        self.libdir = os.path.join(_common.RSRC, 'testlibdir')
-        os.mkdir(self.libdir)
+        self.libdir = os.path.join(self.temp_dir, 'testlibdir')
 
         # Copy a file into the library.
         self.lib = library.Library(':memory:', self.libdir)
         self.album.set_art(artfile)
         os.remove(artfile)
 
-    def tearDown(self):
-        self.io.restore()
-        shutil.rmtree(self.libdir)
-
     def _update(self, query=(), album=False, move=False, reset_mtime=True):
         self.io.addinput('y')
         if reset_mtime:
 
 class PrintTest(_common.TestCase):
     def setUp(self):
-        self.io = _common.DummyIO()
+        super(PrintTest, self).setUp()
         self.io.install()
-    def tearDown(self):
-        self.io.restore()
 
     def test_print_without_locale(self):
         lang = os.environ.get('LANG')
 class AutotagTest(_common.TestCase):
     def setUp(self):
         super(AutotagTest, self).setUp()
-        self.io = _common.DummyIO()
         self.io.install()
-    def tearDown(self):
-        super(AutotagTest, self).tearDown()
-        self.io.restore()
 
     def _no_candidates_test(self, result):
         task = importer.ImportTask(
 
 class InputTest(_common.TestCase):
     def setUp(self):
-        self.io = _common.DummyIO()
+        super(InputTest, self).setUp()
         self.io.install()
-    def tearDown(self):
-        self.io.restore()
 
     def test_manual_search_gets_unicode(self):
         self.io.addinput('\xc3\x82me')
 class ConfigTest(_common.TestCase):
     def setUp(self):
         super(ConfigTest, self).setUp()
-        self.io = _common.DummyIO()
         self.io.install()
         self.test_cmd = ui.Subcommand('test', help='test')
         commands.default_commands.append(self.test_cmd)
     def tearDown(self):
         super(ConfigTest, self).tearDown()
-        self.io.restore()
         commands.default_commands.pop()
     def _run_main(self, args, config_yaml, func):
         self.test_cmd.func = func
 class ShowdiffTest(_common.TestCase):
     def setUp(self):
         super(ShowdiffTest, self).setUp()
-        self.io = _common.DummyIO()
         self.io.install()
-    def tearDown(self):
-        super(ShowdiffTest, self).tearDown()
-        self.io.restore()
 
     def test_showdiff_strings(self):
         commands._showdiff('field', 'old', 'new')
 AN_ID = "28e32c71-1450-463e-92bf-e0a46446fc11"
 class ManualIDTest(_common.TestCase):
     def setUp(self):
+        super(ManualIDTest, self).setUp()
         _common.log.setLevel(logging.CRITICAL)
-        self.io = _common.DummyIO()
         self.io.install()
-    def tearDown(self):
-        self.io.restore()
 
     def test_id_accepted(self):
         self.io.addinput(AN_ID)
 class ShowChangeTest(_common.TestCase):
     def setUp(self):
         super(ShowChangeTest, self).setUp()
-        self.io = _common.DummyIO()
         self.io.install()
 
         self.items = [_common.item()]
                 autotag.TrackInfo('the title', 'track id', index=1)
         ])
 
-    def tearDown(self):
-        super(ShowChangeTest, self).tearDown()
-        self.io.restore()
-
     def _show_change(self, items=None, info=None,
                      cur_artist='the artist', cur_album='the album',
                      dist=0.1):