diff --git a/beetsplug/convert.py b/beetsplug/convert.py new file mode 100644 index 000000000..9554e4279 --- /dev/null +++ b/beetsplug/convert.py @@ -0,0 +1,149 @@ +"""Converts tracks or albums to external directory +""" +import logging +import os +import threading +import shutil +from subprocess import Popen, PIPE + +import imghdr + +from beets.plugins import BeetsPlugin +from beets import ui, library, util, mediafile +from beets.util.functemplate import Template + +log = logging.getLogger('beets') +DEVNULL = open(os.devnull, 'wb') +conf = {} + +def _embed(path, items): + """Embed an image file, located at `path`, into each item. + """ + data = open(util.syspath(path), 'rb').read() + kindstr = imghdr.what(None, data) + if kindstr not in ('jpeg', 'png'): + log.error('A file of type %s is not allowed as coverart.' % kindstr) + return + log.debug('Embedding album art.') + for item in items: + try: + f = mediafile.MediaFile(syspath(item.path)) + except mediafile.UnreadableFileError as exc: + log.warn('Could not embed art in {0}: {1}'.format( + repr(item.path), exc + )) + continue + f.art = data + f.save() + +class encodeThread(threading.Thread): + def __init__(self, source, dest, artpath): + threading.Thread.__init__(self) + self.source = source + self.dest = dest + self.artpath = artpath + + def run(self): + sema.acquire() + self.encode() + sema.release() + + dest_item = library.Item.from_path(self.source) + dest_item.path = self.dest + dest_item.write() + if self.artpath and conf['embed']: + _embed(self.artpath,[dest_item]) + + def encode(self): + log.info('Started encoding '+ self.source) + temp_dest = self.dest + '~' + + source_ext = os.path.splitext(self.source)[1].lower() + if source_ext == '.flac': + decode = Popen([conf['flac'], '-c', '-d', '-s', self.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'] + + [self.source, temp_dest], close_fds=True, stderr=DEVNULL) + encode.communicate() + else: + log.error('Only converting from FLAC or MP3 implemented') + return + shutil.move(temp_dest, self.dest) + log.info('Finished encoding '+ self.source) + + +def convert_item(lib, item, dest_dir, artpath): + if item.format != 'FLAC' and item.format != 'MP3': + log.info('Skipping {0} : not supported format'.format(item.path)) + return + + dest = os.path.join(dest_dir,lib.destination(item, fragment = True)) + dest = os.path.splitext(dest)[0] + '.mp3' + + if not os.path.exists(dest): + util.mkdirall(dest) + if item.format == 'MP3' and item.bitrate < 1000*conf['max_bitrate']: + log.info('Copying {0}'.format(item.path)) + shutil.copy(item.path, dest) + if artpath and conf['embed']: + _embed(artpath,[library.Item.from_path(dest)]) + else: + thread = encodeThread(item.path, dest, artpath) + thread.start() + else: + log.info('Skipping {0} : target file exists'.format(item.path)) + + +def convert_func(lib, config, opts, args): + global sema + + dest = opts.dest if opts.dest is not None else conf['dest'] + if not dest: + log.error('No destination set') + return + threads = opts.threads if opts.threads is not None else conf['threads'] + sema = threading.BoundedSemaphore(threads) + + fmt = '$albumartist - $album' if opts.album \ + else '$artist - $album - $title' + ui.commands.list_items(lib, ui.decargs(args), opts.album, False, fmt) + + if not ui.input_yn("Convert? (Y/n)"): + return + + if opts.album: + for album in lib.albums(ui.decargs(args)): + for item in album.items(): + convert_item(lib, item, dest, album.artpath) + else: + for item in lib.items(ui.decargs(args)): + convert_item(lib, item, dest, lib.get_album(item).artpath) + +class ConvertPlugin(BeetsPlugin): + def configure(self, config): + conf['dest'] = ui.config_val(config, 'convert', 'dest', None) + conf['threads'] = ui.config_val(config, 'convert', 'threads', 2) + conf['flac'] = ui.config_val(config, 'convert', 'flac', 'flac') + conf['lame'] = ui.config_val(config, 'convert', 'lame', 'lame') + conf['opts'] = ui.config_val(config, 'convert', + 'opts', '-V2').split(' ') + conf['max_bitrate'] = int(ui.config_val(config, 'convert', + 'max_bitrate','500')) + conf['embed'] = ui.config_val(config, 'convert', 'embed', True, + vtype = bool) + + def commands(self): + cmd = ui.Subcommand('convert', help='convert to external location') + cmd.parser.add_option('-a', '--album', action='store_true', + help='choose albums instead of tracks') + cmd.parser.add_option('-t', '--threads', action='store', type='int', + help='change the number of threads (default 2)') + cmd.parser.add_option('-d', '--dest', action='store', + help='set the destination directory') + cmd.func = convert_func + return [cmd] diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst new file mode 100644 index 000000000..4ec52b152 --- /dev/null +++ b/docs/plugins/convert.rst @@ -0,0 +1,54 @@ +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. +It will skip files that are already present in the target directory. It uses +the same directory structure as your library. + +Installation +------------ + +This plugin requires ``flac`` and ``lame``. If thoses executables are in your +path, they will be found automatically by the plugin, otherwise you have to set +their respective config options. Of course you will have to enable the plugin +as well (see :doc:`/plugins/index`):: + + [convert] + flac:/usr/bin/flac + lame:/usr/bin/lame + +Usage +----- + +To convert a part of your collection simply run ``beet convert QUERY``. This +will display all items matching ``QUERY`` and ask you for confirmation before +starting the conversion. The ``-a`` (or ``--album``) option causes the command +to match albums instead of tracks. + +The ``-t`` (``--threads``) and ``-d`` (``--dest``) options allow you to specify +or overwrite the respective configuration options. + +Configuration +------------- + +This plugin offers a couple of configuration options: If you want to disable +that album art is embedded in your converted items (enabled by default), you +will have to set the ``embed`` option to false. If you set ``max_bitrate``, all +MP3 files with a higher bitrate will be converted and thoses with a lower +bitrate will simply be copied. Be aware that this doesn't mean that your +converted files will have a lower bitrate since that depends on the specified +encoding options. By default only FLAC files will be converted (and all MP3s +will be copied). ``opts`` are the encoding options that are passed to ``lame`` +(defaults to "-V2"). Please refer to the ``lame`` docs for possible options. + +The ``dest`` sets the directory the files will be converted (or copied) to. +Finally ``threads`` lets you determine the number of threads to use for +encoding (default: 2). An example configuration:: + + [convert] + embed:false + max_bitrate:200 + opts:-V4 + dest:/home/user/MusicForPhone + threads:4 diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index b9a7e64f5..cd94cf104 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -54,6 +54,7 @@ disabled by default, but you can turn them on as described above. fuzzy_search zero ihate + convert Autotagger Extensions '''''''''''''''''''''' @@ -96,6 +97,7 @@ Miscellaneous * :doc:`ihate`: Skip by defined patterns things you hate during import process. * :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 .. _MPD: http://mpd.wikia.com/ .. _MPD clients: http://mpd.wikia.com/wiki/Clients