mkogg.py: Update to Python 3
[blog.git] / posts / mkogg / mkogg.py
index 1c96d0a9e284ff6e0d71fa3c45c2e7e378301ae1..07ef572f522aed5381816927ef558c77b7ec62c9 100755 (executable)
@@ -1,6 +1,6 @@
 #!/usr/bin/env python
 #
-# Copyright (C) 2009-2011 W. Trevor King <wking@drexel.edu>
+# Copyright (C) 2009-2015 W. Trevor King <wking@drexel.edu>
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Lesser General Public License as
 # License along with this program.  If not, see
 # <http://www.gnu.org/licenses/>.
 
-"""Mirror a tree of mp3/ogg/flac files with Ogg Vorbis versions.
+"""Mirror a tree of audio files in another format.
 
-Other target formats are also supported.  Current conversions:
+Conversion between any of the following formats are supported:
 
-* flac -> ogg
-* flac -> wav -> mp3
-* ogg -> wav -> flac
-* ogg -> wav -> mp3
-* mp3 -> wav -> flac
-* mp3 -> wav -> ogg
+* flac
+* mp4 (decoding only)
+* mp3
+* ogg (Vorbis)
+* wav
 
 External packages required for full functionality:
 
-* id3v2_ (`id3v2`)
 * lame_ (`lame`)
-* flac_ (`metaflac`)
+* faad_ (`faad`)
+* flac_ (`flac`)
 * mpg123_ (`mpg123`)
-* vorbis_ (`ogg123`, `oggenc`, `vorbiscomment`)
+* vorbis_ (`ogg123`, `oggenc`)
+* mutagen_ (metadata conversion)
 
-.. _id3v2: http://id3v2.sourceforge.net/
-.. _lame: http://lame.sourceforge.net
-.. _flac: http://flac.sourceforge.net
+.. _lame: http://lame.sourceforge.net/
+.. _faad: http://www.audiocoding.com/faad2.html
+.. _flac: http://flac.sourceforge.net/
 .. _mpg123: http://www.mpg123.org/
-.. _vorbis: http://www.vorbis.com
+.. _vorbis: http://www.vorbis.com/
+.. _mutagen: http://code.google.com/p/mutagen/
 """
 
 from hashlib import sha256 as _hash
-import os
-import os.path
-import shutil
-from subprocess import Popen, PIPE
-from tempfile import mkstemp
+import os as _os
+import re as _re
+import shutil as _shutil
+import subprocess as _subprocess
+import tempfile as _tempfile
 
+try:
+    import mutagen as _mutagen
+    import mutagen.flac as _mutagen_flac
+    import mutagen.id3 as _mutagen_id3
+    import mutagen.mp4 as _mutagen_mp4
+    import mutagen.mp3 as _mutagen_mp3
+    import mutagen.oggvorbis as _mutagen_oggvorbis
+except ImportError as error:
+    _mutagen = None
+    _mutagen_import_error = error
 
-__version__ = '0.2'
+
+__version__ = '0.5'
 
 
 def invoke(args, stdin=None, expect=(0,)):
-    print '  %s' % args
-    p = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE)
+    print('  {}'.format(args))
+    p = _subprocess.Popen(
+        args, stdin=_subprocess.PIPE, stdout=_subprocess.PIPE,
+        stderr=_subprocess.PIPE)
     stdout,stderr = p.communicate(stdin)
     status = p.wait()
-    assert status in expect, 'invalid status %d from %s' % (status, args)
+    assert status in expect, 'invalid status {} from {}'.format(status, args)
     return (status, stdout, stderr)
 
 
@@ -72,23 +86,56 @@ class Converter (object):
 
     The `get_` and `set_*_metadata` methods should pass metadata as a
     `dict` with key/value pairs standardised to match the list of
-    Vorbis comment suggestions_ with lowecase keys.  The `date` field
-    should be formatted `YYYY[-MM[-DD]]`.
+    Vorbis comment suggestions_ with lowercase keys.  The `date` field
+    should be formatted `YYYY[-MM[-DD]]`.  The dict values should be
+    lists to support repeated entries for a given tag.
 
     .. _suggestions: http://www.xiph.org/vorbis/doc/v-comment.html
     """
+    id3_to_vorbis_keys = {
+        'comm': 'comment',
+        'talb': 'album',
+        'tcom': 'composer',
+        'tcop': 'copyright',
+        'tit2': 'title',
+        'tpe1': 'artist',
+        'tpe2': 'accompaniment',
+        'tpe3': 'conductor',
+        'tpos': 'part of set',
+        'tpub': 'organization',  # publisher
+        'trck': 'tracknumber',
+        'tyer': 'date',
+        }
+
+    mp4_to_vorbis_keys = {
+        '\xa9cmt': 'comment',
+        '\xa9alb': 'album',
+        #'tcom': 'composer',
+        'cprt': 'copyright',
+        '\xa9nam': 'title',
+        '\xa9ART': 'artist',
+        #'tpe2': 'accompaniment',
+        #'tpe3': 'conductor',
+        'disk': 'part of set',
+        #'tpub': 'organization',  # publisher
+        'trkn': 'tracknumber',
+        '\xa9day': 'date',
+        }
+
     def __init__(self, source_dir, target_dir, target_extension='ogg',
-                 cache_file=None):
+                 cache_file=None, hash=True, ignore_function=None):
         self.source_dir = source_dir
         self.target_dir = target_dir
-        self._source_extensions = ['flac', 'mp3', 'ogg', 'wav']
+        self._source_extensions = ['flac', 'm4a', 'mp3', 'mp4', 'ogg', 'wav']
         self._target_extension = target_extension
         self._cache_file = cache_file
         self._cache = self._read_cache()
-        f,self._tempfile = mkstemp(prefix='mkogg-')
+        self._hash = hash
+        self._ignore_function = ignore_function
+        f,self._tempfile = _tempfile.mkstemp(prefix='mkogg-')
 
     def cleanup(self):
-        os.remove(self._tempfile)
+        _os.remove(self._tempfile)
         self._save_cache()
 
     def _read_cache(self):
@@ -101,8 +148,8 @@ class Converter (object):
                 assert line.startswith('# mkogg cache version:'), line
                 version = line.split(':', 1)[-1].strip()
                 if version != __version__:
-                    print 'cache version mismatch: %s != %s' % (
-                        version, __version__)
+                    print('cache version mismatch: {} != {}'.format(
+                            version, __version__))
                     return cache  # old cache, ignore contents
                 for line in f:
                     try:
@@ -118,54 +165,71 @@ class Converter (object):
         if self._cache_file == None:
             return
         with open(self._cache_file, 'w') as f:
-            f.write('# mkogg cache version: %s\n' % __version__)
-            for key,value in self._cache.iteritems():
-                f.write('%s -> %s\n' % (key, value))
+            f.write('# mkogg cache version: {}\n'.format(__version__))
+            for key,value in self._cache.items():
+                f.write('{} -> {}\n'.format(key, value))
 
     def run(self):
         self._makedirs(self.target_dir)
-        for dirpath,dirnames,filenames in os.walk(self.source_dir):
+        for dirpath,dirnames,filenames in _os.walk(self.source_dir):
             for filename in filenames:
-                root,ext = os.path.splitext(filename)
+                root,ext = _os.path.splitext(filename)
                 ext = ext.lower()
                 if ext.startswith('.'):
                     ext = ext[1:]
                 if ext not in self._source_extensions:
-                    print 'skip', filename, ext
+                    print('skip', filename, ext)
+                    continue
+                source_path = _os.path.join(dirpath, filename)
+                if (self._ignore_function is not None and
+                    self._ignore_function(source_path)):
                     continue
-                source_path = os.path.join(dirpath, filename)
-                rel_path = os.path.relpath(dirpath, self.source_dir)
-                target_path = os.path.join(
+                rel_path = _os.path.relpath(dirpath, self.source_dir)
+                target_path = _os.path.join(
                     self.target_dir, rel_path,
-                    '%s.%s' % (root, self._target_extension))
-                target_dir = os.path.dirname(target_path)
+                    '{}.{}'.format(root, self._target_extension))
+                target_dir = _os.path.dirname(target_path)
                 self._makedirs(target_dir)
                 self._convert(source_path, target_path, ext)
 
     def _makedirs(self, target_dir):
-        if not os.path.exists(target_dir):
-            os.makedirs(target_dir)
+        if not _os.path.exists(target_dir):
+            _os.makedirs(target_dir)
 
     def _convert(self, source, target, ext):
-        cache_key = self._cache_key(source)
-        old_cache_value = self._cache.get(cache_key, None)
-        if (old_cache_value != None and
-            old_cache_value == self._cache_value(target)):
-            print 'cached %s to %s' % (source, target)
+        if self._hash:
+            cache_key = self._cache_key(source)
+            old_cache_value = self._cache.get(cache_key, None)
+            if (old_cache_value != None and
+                old_cache_value == self._cache_value(target)):
+                print('already cached {} to {}'.format(source, target))
+                return
+        elif _os.path.exists(target):
+            print('target {} already exists'.format(target))
             return
-        print 'convert %s to %s' % (source, target)
+        print('convert {} to {}'.format(source, target))
         if ext == self._target_extension:
-            shutil.copy(source, target)
+            _shutil.copy(source, target)
             return
-        convert = getattr(self, 'convert_%s_to_%s'
-                          % (ext, self._target_extension))
+        try:
+            convert = getattr(self, 'convert_{}_to_{}'.format(
+                    ext, self._target_extension))
+        except AttributeError:
+            to_wav = getattr(self, 'convert_{}_to_wav'.format(ext))
+            from_wav = getattr(self, 'convert_wav_to_{}'.format(
+                    self._target_extension))
+            def convert(source, target):
+                to_wav(source, self._tempfile)
+                from_wav(self._tempfile, target)
         convert(source, target)
         if not getattr(convert, 'handles_metadata', False):
-            get_metadata = getattr(self, 'get_%s_metadata' % ext)
+            get_metadata = getattr(self, 'get_{}_metadata'.format(ext))
             metadata = get_metadata(source)
-            set_metadata = getattr(self, 'set_%s_metadata'
-                                   % self._target_extension)
+            set_metadata = getattr(self, 'set_{}_metadata'.format(
+                    self._target_extension))
             set_metadata(target, metadata)
+        if not self._hash:
+            cache_key = self._cache_key(source)
         self._cache[cache_key] = self._cache_value(target)
 
     def _cache_key(self, source):
@@ -198,6 +262,17 @@ class Converter (object):
             return None
         return str(h.hexdigest())
 
+    def _set_vorbis_comments(self, container, metadata):
+        container.delete()
+        if type(metadata) == dict:
+            items = sorted(metadata.items())
+        else:
+            items = metadata.items()
+        for key,value in items:
+            # leave key case alone, because Mutagen downcases Vorbis
+            # keys internally.
+            container[key] = value
+
     def _parse_date(self, date):
         """Parse `date` (`YYYY[-MM[-DD]]`), returning `(year, month, day)`.
 
@@ -217,134 +292,32 @@ class Converter (object):
         fields = fields + [None] * (3 - len(fields))
         return fields
 
-    def _parse_id3v2_comments(self, stdout):
-        """Parse ID3v2 tags.
-
-        Examples
-        --------
-        >>> from pprint import pprint
-        >>> c = Converter(None, None)
-        >>> metadata = c._parse_id3v2_comments('\\n'.join([
-        ...     'id3v1 tag info for src/03-Drive_My_Car.mp3:',
-        ...     'Title  : The Famous Song     Artist: No One You Know',
-        ...     'Album  : The Famous Album    Year: 1965, Genre: Rock (17)',
-        ...     'Comment:                     Track: 7',
-        ...     'id3v2 tag info for src/03-Drive_My_Car.mp3:',
-        ...     'TALB (Album/Movie/Show title): The Famous Album',
-        ...     'TPE1 (Lead performer(s)/Soloist(s)): No One You Know',
-        ...     'TIT2 (Title/songname/content description): The Famous Song',
-        ...     'TYER (Year): 1965',
-        ...     'TCON (Content type): Rock (17)',
-        ...     'TRCK (Track number/Position in set): 07/14']))
-        >>> pprint(metadata)  # doctest: +REPORT_UDIFF
-        {'album': 'The Famous Album',
-         'artist': 'No One You Know',
-         'date': '1965',
-         'genre': 'Rock',
-         'title': 'The Famous Song',
-         'tracknumber': '07',
-         'tracktotal': '14'}
-        >>> c.cleanup()
-        """
-        metadata = {}
-        vorbis_keys = {
-            'comm': 'comment',
-            'talb': 'album',
-            'tcom': 'composer',
-            'tcon': 'genre',
-            'tcop': 'copyright',
-            'tit2': 'title',
-            'tpe1': 'artist',
-            'tpe2': 'accompaniment',
-            'tpe3': 'conductor',
-            'tpos': 'part of set',
-            'tpub': 'organization',
-            'trck': 'tracknumber',
-            'tyer': 'date',
-            }
-        drop_keys = [
-            'apic',  # attached picture
-            'geob',  # general encapsulated object
-            'ncon',  # ?
-            'pcnt',  # play counter (incremented with each play)
-            'priv',  # private
-            'tco',   # content type
-            'tcp',   # frame?
-            'tenc',  # encoded by
-            'tflt',  # file type
-            'tope',  # original artist (e.g. for a cover)
-            'tlen',  # length (in milliseconds)
-            'tmed',  # media type
-            'wxxx',  # user defined URL
-            ]
-        key_translations = {
-            'com': 'comm',
-            'ten': 'tenc',
-            'tal': 'talb',
-            'tcm': 'tcom',
-            'tt2': 'tit2',
-            'tp1': 'tpe1',
-            'trk': 'trck',
-            'tye': 'tyer',
-            }
-        in_v2 = False
-        for line in stdout.splitlines():
-            if not in_v2:
-                if line.startswith('id3v2 tag info'):
-                    in_v2 = True
-                continue
-            key,value = [x.strip() for x in line.split(':', 1)]
-            if value.lower() == 'no id3v1 tag':
-                continue
-            short_key = key.split()[0].lower()
-            short_key = key_translations.get(short_key, short_key)
-            if short_key in drop_keys:
-                continue
-            v_key = vorbis_keys[short_key]
-            if v_key == 'genre':
-                value = value.rsplit('(', 1)[0].strip()
-            elif v_key == 'tracknumber' and '/' in value:
-                value,total = value.split('/')
-                metadata['tracktotal'] = total
-            metadata[v_key] = value
-        return metadata
-
-    def _parse_vorbis_comments(self, stdout):
-        """Parse Vorbis comments.
-
-        Examples
-        --------
-        >>> from pprint import pprint
-        >>> c = Converter(None, None)
-        >>> metadata = c._parse_vorbis_comments('\\n'.join([
-        ...     'ARTIST=No One You Know',
-        ...     'ALBUM=The Famous Album',
-        ...     'TITLE=The Famous Song',
-        ...     'DATE=1965',
-        ...     'GENRE=Rock',
-        ...     'TRACKNUMBER=07',
-        ...     'TRACKTOTAL=14',
-        ...     'CDDB=af08640e']))
-        >>> pprint(metadata)  # doctest: +REPORT_UDIFF
-        {'album': 'The Famous Album',
-         'artist': 'No One You Know',
-         'cddb': 'af08640e',
-         'date': '1965',
-         'genre': 'Rock',
-         'title': 'The Famous Song',
-         'tracknumber': '07',
-         'tracktotal': '14'}
-        >>> c.cleanup()
-        """
-        metadata = {}
-        for line in stdout.splitlines():
-            key,value = line.split('=', 1)
-            metadata[key.lower()] = value
-        return metadata
-
-    def convert_flac_to_mp3(self, source, target):
-        self.convert_flac_to_wav(source, self._tempfile)
-        self.convert_wav_to_mp3(self._tempfile, target)
+    def _construct_id3_trck(self, metadata):
+        if 'tracknumber' not in metadata:
+            return (None, None)
+        if 'tracktotal' in metadata:
+            value = []
+            for i,v in enumerate(metadata['tracknumber']):
+                value.append('{}/{}'.format(
+                        v, metadata['tracktotal'][i]))
+        else:
+            value = metadata['tracknumber']
+        key = 'tracknumber'
+        return (key, value)
+
+    def _guess_id3_encoding(self, text_list):
+        for id3_encoding,encoding in [(0, 'ISO-8859-1'), (3, 'utf-8')]:
+            encoding_success = True
+            for text in text_list:
+                if isinstance(text, unicode):
+                    try:
+                        text.encode(encoding)
+                    except UnicodeEncodeError:
+                        encoding_success = False
+                        break
+            if encoding_success:
+                return id3_encoding
+        raise ValueError(text_list)
 
     def convert_flac_to_wav(self, source, target):
         invoke(['ogg123', '-d', 'wav', '-f', target, source])
@@ -353,25 +326,20 @@ class Converter (object):
         invoke(['oggenc', '--quiet', '-q', '3', source, '-o', target])
     convert_flac_to_ogg.handles_metadata = True
 
-    def convert_mp3_to_flac(self, source, target):
-        self.convert_mp3_to_wav(source, self._tempfile)
-        self.convert_wav_to_flac(self._tempfile, target)
-
-    def convert_mp3_to_ogg(self, source, target):
-        self.convert_mp3_to_wav(source, self._tempfile)
-        self.convert_wav_to_ogg(self._tempfile, target)
+    def convert_m4a_to_wav(self, source, target):
+        invoke(['faad', '-o', target, source])
 
     def convert_mp3_to_wav(self, source, target):
         invoke(['mpg123',  '-w', target, source])
 
-    def convert_ogg_to_mp3(self, source, target):
-        self.convert_flac_to_mp3(source, target)
+    def convert_mp4_to_wav(self, source, target):
+        invoke(['faad', '-o', target, source])
 
     def convert_ogg_to_wav(self, source, target):
-        self.convert_flac_to_wav(source_target)
+        self.convert_flac_to_wav(sourcetarget)
 
     def convert_wav_to_flac(self, source, target):
-        invoke(['flac', '-o', target, source])
+        invoke(['flac', '--force', '--output-name', target, source])
 
     def convert_wav_to_mp3(self, source, target):
         invoke(['lame', '--quiet', '-V', '4', source, target])
@@ -380,70 +348,118 @@ class Converter (object):
         self.convert_flac_to_ogg(source, target)
 
     def get_flac_metadata(self, source):
-        status,stdout,stderr = invoke(
-            ['metaflac', '--export-tags-to=-', source])
-        metadata = {}
-        for line in stdout.splitlines():
-            key,value = line.split('=', 1)
-            metadata[key.lower()] = value
-        return metadata
+        if _mutagen is None:
+            raise _mutagen_import_error
+        return _mutagen_flac.FLAC(source)
 
-    def get_flac_metadata(self, source):
-        status,stdout,stderr = invoke(
-            ['metaflac', '--export-tags-to=-', source])
-        return self._parse_vorbis_comments(stdout)
+    def get_m4a_metadata(self, source):
+        return self.get_mp4_metadata(self, source)
 
     def get_mp3_metadata(self, source):
-        status,stdout,stderr = invoke(
-            ['id3v2', '--list', source])
-        return self._parse_id3v2_comments(stdout)
+        if _mutagen is None:
+            raise _mutagen_import_error
+        mp3 = _mutagen_mp3.MP3(source)
+        metadata = {}
+        for key,value in mp3.items():
+            try:
+                vorbis_key = self.id3_to_vorbis_keys[key.lower()]
+            except KeyError:
+                continue
+            v = value.text
+            if vorbis_key == 'tracknumber':
+                for i,v_entry in enumerate(v):
+                    if '/' in v_entry:
+                        tracknumber,tracktotal = v_entry.split('/', 1)
+                        v[i] = tracknumber
+                        metadata['tracktotal'] = ['tracktotal']
+            metadata[vorbis_key] = v
+        return metadata
+
+    def get_mp4_metadata(self, source):
+        if _mutagen is None:
+            raise _mutagen_import_error
+        mp4 = _mutagen_mp4.MP4(source)
+        metadata = {}
+        for key,value in mp4.items():
+            try:
+                vorbis_key = self.mp4_to_vorbis_keys[key.lower()]
+            except KeyError:
+                continue
+            if vorbis_key == 'tracknumber':
+                tracknumber,tracktotal = value
+                value = tracknumber
+                if tracktotal:
+                    metadata['tracktotal'] = [str(tracktotal)]
+            elif vorbis_key == 'part of set':
+                disknumber,disktotal = value
+                value = disknumber
+                if disktotal:
+                    metadata['set total'] = [str(disktotal)]
+            try:
+                metadata[vorbis_key] = [str(value)]
+            except UnicodeEncodeError:
+                metadata[vorbis_key] = [value]
+        return metadata
 
     def get_ogg_metadata(self, source):
-        status,stdout,stderr = invoke(
-            ['vorbiscomment', '--list', source])
-        return self._parse_vorbis_comments(stdout)
+        if _mutagen is None:
+            raise _mutagen_import_error        
+        return _mutagen_oggvorbis.OggVorbis(source)
 
     def get_wav_metadata(self, source):
         return {}
 
     def set_flac_metadata(self, target, metadata):
-        stdin = '\n'.join(['%s=%s' % (k.upper(), v)
-                           for k,v in sorted(metadata.iteritems())])
-        invoke(['metaflac', '--import-tags-from=-', target], stdin=stdin)
+        if _mutagen is None:
+            raise _mutagen_import_error
+        flac = _mutagen_flac.FLAC(target)
+        self._set_vorbis_comments(flac, metadata)
+        flac.save()
 
     def set_mp3_metadata(self, target, metadata):
-        args = ['id3v2']
-        for key,arg in [('album', '--album'), ('artist', '--artist'),
-                        ('title', '--song')]:
-            if key in metadata:
-                args.extend([arg, metadata[key]])
-        if 'date' in metadata:
-            year,month,day = self._parse_date(metadata['date'])
-            args.extend(['--year', year])
-        if 'genre' in metadata:
-            genre = metadata['genre']
-            if not hasattr(self, '_id3v1_genres'):
-                status,stdout,stderr = invoke(['id3v2', '--list-genres'])
-                genres = {}
-                for line in stdout.splitlines():
-                    num,name = [x.strip() for x in line.split(':', 1)]
-                    genres[name.lower()] = num
-                self._id3v1_genres = genres
-            # Genre 12 = "Other"
-            num = self._id3v1_genres.get(genre.lower(), '12')
-            args.extend(['--genre', num])
-        if 'tracknumber' in metadata:
-            track = metadata['tracknumber']
-            if 'tracktotal' in metadata:
-                track = '%s/%s' % (track, metadata['tracktotal'])
-            args.extend(['--track', track])
-        args.append(target)
-        invoke(args)
+        vorbis_keys_to_id3 = dict(
+            (v,k) for k,v in self.id3_to_vorbis_keys.items())
+        if _mutagen is None:
+            raise _mutagen_import_error
+        mp3 = _mutagen_mp3.MP3(target)
+        if mp3.tags is not None:
+            mp3.tags.delete()
+        handled_trck = False
+        max_encoding = 0
+        for key,value in metadata.items():
+            if key == 'date':
+                for i,v in enumerate(value):
+                    year,month,day = self._parse_date(v)
+                    value[i] = year
+            elif key in ['tracknumber', 'tracktotal']:
+                if handled_trck is True:
+                    continue
+                handled_trck = True
+                key,value = self._construct_id3_trck(metadata)
+                if value is None:
+                    continue
+            try:
+                frame_name = vorbis_keys_to_id3[key].upper()
+            except KeyError:
+                continue
+            frame = getattr(_mutagen_id3, frame_name)
+            id3_encoding = self._guess_id3_encoding(value)
+            max_encoding = max(max_encoding, id3_encoding)
+            mp3[frame_name] = frame(encoding=id3_encoding, text=value)
+        if mp3.tags is None:
+            return
+        if max_encoding:  # at least one tag doesn't use ISO-8859-1
+            v1 = 0  # remove ID3v1 tags
+        else:
+            v1 = 2  # create and/or update ID3v1 tags
+        mp3.save(v1=v1)
 
     def set_ogg_metadata(self, target, metadata):
-        stdin = '\n'.join(['%s=%s' % (k.upper(), v)
-                           for k,v in sorted(metadata.iteritems())])
-        invoke(['vorbiscomment', '--write', target], stdin=stdin)
+        if _mutagen is None:
+            raise _mutagen_import_error
+        ogg = _mutagen_oggvorbis.OggVorbis(target)
+        self._set_vorbis_comments(ogg, metadata)
+        ogg.save()
 
     def set_wav_metadata(self, target, metadata):
         pass
@@ -456,30 +472,62 @@ def test():
 
 
 if __name__ == '__main__':
-    import optparse
+    import argparse
     import sys
 
-    usage = '%prog [options] source-dir target-dir'
-    epilog = __doc__
-    p = optparse.OptionParser(usage=usage, epilog=epilog)
-    p.format_epilog = lambda formatter: epilog+'\n'
-    p.add_option('-t', '--target-extension', dest='ext',
-                 default='ogg', metavar='EXT',
-                 help='Conversion target type (e.g. flac, mp3) (%default)')
-    p.add_option('-c', '--cache', dest='cache', metavar='PATH',
-                 help=('Save conversion hashes in a cache file to avoid '
-                       'repeated previous conversions.'))
-    p.add_option('--test', dest='test', action='store_true', default=False,
-                 help='Run internal tests and exit')
-
-    options,args = p.parse_args()
-
-    if options.test:
+    class Formatter (argparse.RawDescriptionHelpFormatter,
+                     argparse.ArgumentDefaultsHelpFormatter):
+        pass
+
+    p = argparse.ArgumentParser(
+        description=__doc__.splitlines()[0],
+        epilog='\n'.join(__doc__.splitlines()[2:]),
+        formatter_class=Formatter)
+    p.add_argument(
+        '-v', '--version', action='version',
+        version='%(prog)s {}'.format(__version__))
+    p.add_argument(
+        '-t', '--target-extension', dest='ext', metavar='EXT',
+        default='ogg', choices=['flac', 'mp3', 'ogg', 'wav'],
+        help='Conversion target type')
+    p.add_argument(
+        '-c', '--cache', dest='cache', metavar='PATH',
+        help=('Save conversion hashes in a cache file to avoid '
+              'repeated previous conversions.'))
+    p.add_argument(
+        '-n', '--no-hash', dest='hash',
+        default=True, action='store_const', const=False,
+        help=("Don't hash files.  Just assume matching names would "
+              'have matching hashes.'))
+    p.add_argument(
+        '-i', '--ignore', dest='ignore', metavar='REGEXP',
+        help='Ignore source paths matching REGEXP.')
+    p.add_argument(
+        '--test', dest='test',
+        default=False, action='store_const', const=True,
+        help='Run internal tests and exit')
+    p.add_argument(
+        'source_dir', metavar='SOURCE', default='.',
+        help='Source directory')
+    p.add_argument(
+        'target_dir', metavar='TARGET', default='.',
+        help='Target directory')
+
+    args = p.parse_args()
+
+    if args.test:
         sys.exit(test())
 
-    source_dir,target_dir = args
-    c = Converter(source_dir, target_dir, target_extension=options.ext,
-                  cache_file=options.cache)
+    if args.ignore:
+        ignore_regexp = _re.compile(args.ignore)
+        ignore_function = ignore_regexp.match
+    else:
+        ignore_function = None
+
+    c = Converter(
+        args.source_dir, args.target_dir, target_extension=args.ext,
+        cache_file=args.cache, hash=args.hash,
+        ignore_function=ignore_function)
     try:
         c.run()
     finally: