diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 428de312a..e91a12f91 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -24,6 +24,7 @@ from collections import defaultdict import traceback import subprocess import platform +from sets import Set MAX_FILENAME_LENGTH = 200 @@ -201,10 +202,25 @@ def sorted_walk(path, ignore=(), logger=None): yield res -def mkdirall(path): +# We don't create directories on dry-runs, but we must pretend they exist +directories_created = Set() + + +def mkdirall(path, pretend=False): """Make all the enclosing directories of path (like mkdir -p on the parent). """ + + if pretend: + # directory = syspath(ancestry(path)[-1]) # "dirname" + # This seems cleaner but MAY have differences on symlinks (leading to + # an equivalent result) + directory = os.path.dirname(path) + if directory not in directories_created: + directories_created.add(directory) + # This is not a "raw" translation, but it's brief one + print("mkdir -p '%s'" % (directory)) + return for ancestor in ancestry(path): if not os.path.isdir(syspath(ancestor)): try: @@ -388,10 +404,13 @@ def samefile(p1, p2): return shutil._samefile(syspath(p1), syspath(p2)) -def remove(path, soft=True): +def remove(path, soft=True, pretend=False): """Remove the file. If `soft`, then no error will be raised if the file does not exist. """ + if pretend: + print("rm '%s'" % (path)) + return path = syspath(path) if soft and not os.path.exists(path): return @@ -401,12 +420,15 @@ def remove(path, soft=True): raise FilesystemError(exc, 'delete', (path,), traceback.format_exc()) -def copy(path, dest, replace=False): +def copy(path, dest, replace=False, pretend=False): """Copy a plain file. Permissions are not copied. If `dest` already exists, raises a FilesystemError unless `replace` is True. Has no effect if `path` is the same as `dest`. Paths are translated to system paths before the syscall. """ + if pretend: + print("cp '%s' '%s'" % (path, dest)) + return if samefile(path, dest): return path = syspath(path) @@ -420,7 +442,7 @@ def copy(path, dest, replace=False): traceback.format_exc()) -def move(path, dest, replace=False): +def move(path, dest, replace=False, pretend=False): """Rename a file. `dest` may not be a directory. If `dest` already exists, raises an OSError unless `replace` is True. Has no effect if `path` is the same as `dest`. If the paths are on different @@ -428,6 +450,9 @@ def move(path, dest, replace=False): instead, in which case metadata will *not* be preserved. Paths are translated to system paths. """ + if pretend: + print("mv '%s' '%s'" % (path, dest)) + return if samefile(path, dest): return path = syspath(path) @@ -618,7 +643,7 @@ def cpu_count(): return 1 -def command_output(cmd, shell=False): +def command_output(cmd, shell=False, pretend=False): """Runs the command and returns its output after it has exited. ``cmd`` is a list of arguments starting with the command names. If @@ -633,6 +658,9 @@ def command_output(cmd, shell=False): Python 2.6 and which can have problems if lots of output is sent to stderr. """ + if pretend: + print(cmd) + return with open(os.devnull, 'wb') as devnull: proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=devnull, close_fds=platform.system() != 'Windows', diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 8113c86ca..a94f4c284 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -81,7 +81,7 @@ def get_format(format=None): return (command.encode('utf8'), extension.encode('utf8')) -def encode(command, source, dest): +def encode(command, source, dest, pretend=False): """Encode `source` to `dest` using command template `command`. Raises `subprocess.CalledProcessError` if the command exited with a @@ -89,7 +89,7 @@ def encode(command, source, dest): """ quiet = config['convert']['quiet'].get() - if not quiet: + if not quiet and not pretend: log.info(u'Started encoding {0}'.format(util.displayable_path(source))) command = Template(command).safe_substitute({ @@ -101,7 +101,9 @@ def encode(command, source, dest): .format(util.displayable_path(command))) try: - util.command_output(command, shell=True) + util.command_output(command, shell=True, pretend=pretend) + if pretend: + return except subprocess.CalledProcessError: # Something went wrong (probably Ctrl+C), remove temporary files log.info(u'Encoding {0} failed. Cleaning up...' @@ -114,7 +116,7 @@ def encode(command, source, dest): u"convert: could invoke '{0}': {0}".format(command, exc) ) - if not quiet: + if not quiet and not pretend: log.info(u'Finished encoding {0}'.format( util.displayable_path(source)) ) @@ -130,7 +132,8 @@ def should_transcode(item): item.bitrate >= 1000 * maxbr -def convert_item(dest_dir, keep_new, path_formats, command, ext): +def convert_item(dest_dir, keep_new, path_formats, command, ext, + pretend=False): while True: item = yield dest = item.destination(basedir=dest_dir, path_formats=path_formats) @@ -150,7 +153,7 @@ def convert_item(dest_dir, keep_new, path_formats, command, ext): # time. (The existence check is not atomic with the directory # creation inside this function.) with _fs_lock: - util.mkdirall(dest) + util.mkdirall(dest, pretend) if os.path.exists(util.syspath(dest)): log.info(u'Skipping {0} (target file exists)'.format( @@ -161,18 +164,22 @@ def convert_item(dest_dir, keep_new, path_formats, command, ext): if keep_new: log.info(u'Moving to {0}'. format(util.displayable_path(original))) - util.move(item.path, original) + util.move(item.path, original, pretend) if not should_transcode(item): # No transcoding necessary. log.info(u'Copying {0}'.format(util.displayable_path(item.path))) - util.copy(original, converted) + util.copy(original, converted, pretend) else: try: - encode(command, original, converted) + encode(command, original, converted, pretend) except subprocess.CalledProcessError: continue + if pretend: + # Should we add support for tagging and after_convert plugins? + continue # A yield is used at the start of the loop + # Write tags from the database to the converted file. item.write(path=converted) @@ -230,21 +237,27 @@ def convert_func(lib, opts, args): command, ext = get_format(opts.format) - ui.commands.list_items(lib, ui.decargs(args), opts.album, None) + pretend = opts.pretend if opts.pretend is not None else \ + config['convert']['pretend'].get() - if not ui.input_yn("Convert? (Y/n)"): - return + if not pretend: + ui.commands.list_items(lib, ui.decargs(args), opts.album, None) + + if not ui.input_yn("Convert? (Y/n)"): + return if opts.album: items = (i for a in lib.albums(ui.decargs(args)) for i in a.items()) else: items = iter(lib.items(ui.decargs(args))) - convert_stages = [] - for i in range(opts.threads): - convert_stages.append( - convert_item(opts.dest, opts.keep_new, path_formats, command, ext) - ) - pipe = util.pipeline.Pipeline([items, convert_stages]) + convert = [convert_item(opts.dest, + opts.keep_new, + path_formats, + command, + ext, + pretend) + for _ in range(opts.threads)] + pipe = util.pipeline.Pipeline([items, convert]) pipe.run_parallel() @@ -253,6 +266,7 @@ class ConvertPlugin(BeetsPlugin): super(ConvertPlugin, self).__init__() self.config.add({ u'dest': None, + u'pretend': False, u'threads': util.cpu_count(), u'format': u'mp3', u'formats': { @@ -284,6 +298,8 @@ class ConvertPlugin(BeetsPlugin): def commands(self): cmd = ui.Subcommand('convert', help='convert to external location') + cmd.parser.add_option('-p', '--pretend', action='store_true', + help='only show what would happen') 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',