From bbf974e5818959940549cfbf5c6f8de1dc95d03b Mon Sep 17 00:00:00 2001 From: Jakob Schnitzer Date: Fri, 5 Oct 2012 20:56:59 +0200 Subject: [PATCH 1/5] First version of convert plugin --- beetsplug/convert.py | 111 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 beetsplug/convert.py diff --git a/beetsplug/convert.py b/beetsplug/convert.py new file mode 100644 index 000000000..703a61321 --- /dev/null +++ b/beetsplug/convert.py @@ -0,0 +1,111 @@ +# Copyright 2012, Jakob Schnitzer. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Converts tracks or albums to external directory +""" +import logging +import os +import subprocess +import os.path + +from beets.plugins import BeetsPlugin +from beets import ui, library, util, mediafile +from beets.util.functemplate import Template + +log = logging.getLogger('beets') + +def _embed(path, items): + """Embed an image file, located at `path`, into each item. + """ + data = open(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() + +def convert_track(source, dest): + with open(os.devnull, "w") as fnull: + subprocess.call('flac -cd "{0}" | lame -V2 - "{1}"'.format(source, dest), + stdout=fnull, stderr=fnull, shell=True) + + +def convert_item(lib, item, dest, artpath): + dest_path = os.path.join(dest,lib.destination(item, fragment = True)) + dest_path = os.path.splitext(dest_path)[0] + '.mp3' + if not os.path.exists(dest_path): + util.mkdirall(dest_path) + log.info('Encoding '+ item.path) + convert_track(item.path, dest_path) + converted_item = library.Item.from_path(dest_path) + converted_item.read(item.path) + converted_item.path = dest_path + converted_item.write() + if artpath: + _embed(artpath,[converted_item]) + else: + log.info('Skipping '+item.path) + +def convert_func(lib, config, opts, args): + if not conf['dest']: + log.error('No destination set') + return + if opts.album: + fmt = u'$albumartist - $album' + else: + fmt = u'$artist - $album - $title' + template = Template(fmt) + if opts.album: + objs = lib.albums(ui.decargs(args)) + else: + objs = list(lib.items(ui.decargs(args))) + + for o in objs: + if opts.album: + ui.print_(o.evaluate_template(template)) + else: + ui.print_(o.evaluate_template(template, lib)) + + if not ui.input_yn("Convert? (Y/n)"): + return + + for o in objs: + if opts.album: + for item in o.items(): + convert_item(lib, item, conf['dest'], o.artpath) + else: + album = lib.get_album(o) + convert_item(lib, o, conf['dest'], album.artpath) + +conf = {} + +class ConvertPlugin(BeetsPlugin): + def configure(self, config): + conf['dest'] = ui.config_val(config, 'convert', 'path', None) + + def commands(self): + cmd = ui.Subcommand('convert', help='convert albums to external location') + cmd.parser.add_option('-a', '--album', action='store_true', + help='choose an album instead of track') + cmd.func = convert_func + return [cmd] From 3d580fc933a3ec15a1be5bbfaaa69c954b39b491 Mon Sep 17 00:00:00 2001 From: Jakob Schnitzer Date: Fri, 5 Oct 2012 23:04:50 +0200 Subject: [PATCH 2/5] Added threads, cleaned up some of the code --- beetsplug/convert.py | 86 +++++++++++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 33 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 703a61321..1a3afc260 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -17,6 +17,8 @@ import logging import os import subprocess import os.path +import threading +import imghdr from beets.plugins import BeetsPlugin from beets import ui, library, util, mediafile @@ -27,7 +29,7 @@ log = logging.getLogger('beets') def _embed(path, items): """Embed an image file, located at `path`, into each item. """ - data = open(syspath(path), 'rb').read() + 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) @@ -44,58 +46,74 @@ def _embed(path, items): f.art = data f.save() -def convert_track(source, dest): - with open(os.devnull, "w") as fnull: - subprocess.call('flac -cd "{0}" | lame -V2 - "{1}"'.format(source, dest), - stdout=fnull, stderr=fnull, shell=True) +global sema + +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() + log.info('Started encoding '+ self.source) + temp_dest = self.dest + "~" + + decode = subprocess.Popen(["flac", "-c", "-d", "-s", self.source], stdout=subprocess.PIPE) + encode = subprocess.Popen(['lame', '-V2', '-', temp_dest], stdin=decode.stdout) + decode.stdout.close() + encode.communicate() + + os.rename(temp_dest, self.dest) + converted_item = library.Item.from_path(self.dest) + converted_item.read(self.source) + converted_item.path = self.dest + converted_item.write() + if self.artpath: + _embed(self.artpath,[converted_item]) + log.info('Finished encoding '+ self.source) + sema.release() def convert_item(lib, item, dest, artpath): + if item.format != "FLAC": + log.info('Skipping {0} : not FLAC'.format(item.path)) + return dest_path = os.path.join(dest,lib.destination(item, fragment = True)) dest_path = os.path.splitext(dest_path)[0] + '.mp3' if not os.path.exists(dest_path): util.mkdirall(dest_path) - log.info('Encoding '+ item.path) - convert_track(item.path, dest_path) - converted_item = library.Item.from_path(dest_path) - converted_item.read(item.path) - converted_item.path = dest_path - converted_item.write() - if artpath: - _embed(artpath,[converted_item]) + thread = encodeThread(item.path, dest_path, artpath) + thread.start() else: - log.info('Skipping '+item.path) + log.info('Skipping {0} : target file exists'.format(item.path)) + def convert_func(lib, config, opts, args): + global sema if not conf['dest']: log.error('No destination set') return + sema = threading.BoundedSemaphore(opts.threads) if opts.album: - fmt = u'$albumartist - $album' + fmt = '$albumartist - $album' else: - fmt = u'$artist - $album - $title' - template = Template(fmt) - if opts.album: - objs = lib.albums(ui.decargs(args)) - else: - objs = list(lib.items(ui.decargs(args))) + fmt = '$artist - $album - $title' - for o in objs: - if opts.album: - ui.print_(o.evaluate_template(template)) - else: - ui.print_(o.evaluate_template(template, lib)) + ui.commands.list_items(lib, ui.decargs(args), opts.album, False, fmt) if not ui.input_yn("Convert? (Y/n)"): return - for o in objs: - if opts.album: - for item in o.items(): + if opts.album: + for album in lib.albums(ui.decargs(args)): + for item in album.items(): convert_item(lib, item, conf['dest'], o.artpath) - else: - album = lib.get_album(o) - convert_item(lib, o, conf['dest'], album.artpath) + else: + for item in lib.items(ui.decargs(args)): + album = lib.get_album(item) + convert_item(lib, item, conf['dest'], album.artpath) conf = {} @@ -106,6 +124,8 @@ class ConvertPlugin(BeetsPlugin): def commands(self): cmd = ui.Subcommand('convert', help='convert albums to external location') cmd.parser.add_option('-a', '--album', action='store_true', - help='choose an album instead of track') + help='choose albums instead of tracks') + cmd.parser.add_option('-t', '--threads', action='store', type='int', + help='change the number of threads (default 2)', default=2) cmd.func = convert_func return [cmd] From d1ab9267d09ab826a10cc76b6f4bcd974e2f9794 Mon Sep 17 00:00:00 2001 From: Jakob Schnitzer Date: Sun, 7 Oct 2012 22:19:15 +0200 Subject: [PATCH 3/5] Added lots of options, support MP3 as source --- beetsplug/convert.py | 128 ++++++++++++++++++++++++------------------- 1 file changed, 72 insertions(+), 56 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 1a3afc260..869edc7b4 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -1,23 +1,11 @@ -# Copyright 2012, Jakob Schnitzer. -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - """Converts tracks or albums to external directory """ import logging import os -import subprocess -import os.path import threading +import shutil +from subprocess import Popen, PIPE + import imghdr from beets.plugins import BeetsPlugin @@ -25,6 +13,8 @@ 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. @@ -46,8 +36,6 @@ def _embed(path, items): f.art = data f.save() -global sema - class encodeThread(threading.Thread): def __init__(self, source, dest, artpath): threading.Thread.__init__(self) @@ -57,50 +45,72 @@ class encodeThread(threading.Thread): def run(self): sema.acquire() - log.info('Started encoding '+ self.source) - temp_dest = self.dest + "~" - - decode = subprocess.Popen(["flac", "-c", "-d", "-s", self.source], stdout=subprocess.PIPE) - encode = subprocess.Popen(['lame', '-V2', '-', temp_dest], stdin=decode.stdout) - decode.stdout.close() - encode.communicate() - - os.rename(temp_dest, self.dest) - converted_item = library.Item.from_path(self.dest) - converted_item.read(self.source) - converted_item.path = self.dest - converted_item.write() - if self.artpath: - _embed(self.artpath,[converted_item]) - log.info('Finished encoding '+ self.source) + self.encode() sema.release() + dest_item = library.Item.from_path(self.source) + dest_item.path = self.dest + dest_item.write() + if self.artpath: + _embed(self.artpath,[dest_item]) -def convert_item(lib, item, dest, artpath): - if item.format != "FLAC": - log.info('Skipping {0} : not FLAC'.format(item.path)) + 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_path = os.path.join(dest,lib.destination(item, fragment = True)) - dest_path = os.path.splitext(dest_path)[0] + '.mp3' - if not os.path.exists(dest_path): - util.mkdirall(dest_path) - thread = encodeThread(item.path, dest_path, artpath) - thread.start() + + 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: + _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 - if not conf['dest']: + + dest = opts.dest if opts.dest is not None else conf['dest'] + if not dest: log.error('No destination set') return - sema = threading.BoundedSemaphore(opts.threads) - if opts.album: - fmt = '$albumartist - $album' - else: - fmt = '$artist - $album - $title' + 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)"): @@ -109,23 +119,29 @@ def convert_func(lib, config, opts, args): if opts.album: for album in lib.albums(ui.decargs(args)): for item in album.items(): - convert_item(lib, item, conf['dest'], o.artpath) + convert_item(lib, item, dest, album.artpath) else: for item in lib.items(ui.decargs(args)): - album = lib.get_album(item) - convert_item(lib, item, conf['dest'], album.artpath) - -conf = {} + convert_item(lib, item, dest, lib.get_album(item).artpath) class ConvertPlugin(BeetsPlugin): def configure(self, config): - conf['dest'] = ui.config_val(config, 'convert', 'path', None) + 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')) def commands(self): - cmd = ui.Subcommand('convert', help='convert albums to external location') + 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)', default=2) + 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] From aa3a66daad661860a0ac352f8168e4179c6b7c42 Mon Sep 17 00:00:00 2001 From: Jakob Schnitzer Date: Mon, 8 Oct 2012 11:26:33 +0200 Subject: [PATCH 4/5] Add option to disable embedding --- beetsplug/convert.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 869edc7b4..9554e4279 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -51,7 +51,7 @@ class encodeThread(threading.Thread): dest_item = library.Item.from_path(self.source) dest_item.path = self.dest dest_item.write() - if self.artpath: + if self.artpath and conf['embed']: _embed(self.artpath,[dest_item]) def encode(self): @@ -90,7 +90,7 @@ def convert_item(lib, item, dest_dir, artpath): 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: + if artpath and conf['embed']: _embed(artpath,[library.Item.from_path(dest)]) else: thread = encodeThread(item.path, dest, artpath) @@ -134,6 +134,8 @@ class ConvertPlugin(BeetsPlugin): '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') From ec6bbf53d4b34ee906ebf901cd62818d2c23f54d Mon Sep 17 00:00:00 2001 From: Jakob Schnitzer Date: Mon, 8 Oct 2012 12:25:56 +0200 Subject: [PATCH 5/5] convert: Add docs --- docs/plugins/convert.rst | 54 ++++++++++++++++++++++++++++++++++++++++ docs/plugins/index.rst | 2 ++ 2 files changed, 56 insertions(+) create mode 100644 docs/plugins/convert.rst 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 757d8a3ac..6ed238f0d 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -53,6 +53,7 @@ disabled by default, but you can turn them on as described above. the fuzzy_search zero + convert Autotagger Extensions '''''''''''''''''''''' @@ -94,6 +95,7 @@ Miscellaneous * :doc:`mbcollection`: Maintain your MusicBrainz collection list. * :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