Repository: beetbox/beets

Issue:
convert: add option to symlink instead of copying
As proposed in #2324.


Discussion:
@sampsyo Thanks for the quick feedback. I did think it felt quite lengthy when I wrote it, but I wanted to ensure users are aware that embed (which is on by default) and link used together may cause the original files to be modified. Maybe a better solution would be to add an "are you sure?" prompt in the code when this combination of options is detected; what do you think?
Sure! Or here are two other ideas:

- Just a short phrase like "(don't use with `embed`, probably)" as a warning there.
- The plugin could just refuse to run with both flags enabled—I doubt anyone will actually want that behavior.

Resolving patch:
diff --git a/beetsplug/convert.py b/beetsplug/convert.py
--- a/beetsplug/convert.py
+++ b/beetsplug/convert.py
@@ -114,6 +114,8 @@ def __init__(self):
         self.config.add({
             u'dest': None,
             u'pretend': False,
+            u'link': False,
+            u'hardlink': False,
             u'threads': util.cpu_count(),
             u'format': u'mp3',
             u'id3v23': u'inherit',
@@ -167,6 +169,13 @@ def commands(self):
                               help=u'set the target format of the tracks')
         cmd.parser.add_option('-y', '--yes', action='store_true', dest='yes',
                               help=u'do not ask for confirmation')
+        cmd.parser.add_option('-l', '--link', action='store_true', dest='link',
+                              help=u'symlink files that do not \
+                              need transcoding.')
+        cmd.parser.add_option('-H', '--hardlink', action='store_true',
+                              dest='hardlink',
+                              help=u'hardlink files that do not \
+                              need transcoding. Overrides --link.')
         cmd.parser.add_album_option()
         cmd.func = self.convert_func
         return [cmd]
@@ -251,7 +260,7 @@ def encode(self, command, source, dest, pretend=False):
                            util.displayable_path(source))
 
     def convert_item(self, dest_dir, keep_new, path_formats, fmt,
-                     pretend=False):
+                     pretend=False, link=False, hardlink=False):
         """A pipeline thread that converts `Item` objects from a
         library.
         """
@@ -299,20 +308,35 @@ def convert_item(self, dest_dir, keep_new, path_formats, fmt,
                     util.move(item.path, original)
 
             if should_transcode(item, fmt):
+                linked = False
                 try:
                     self.encode(command, original, converted, pretend)
                 except subprocess.CalledProcessError:
                     continue
             else:
+                linked = link or hardlink
                 if pretend:
-                    self._log.info(u'cp {0} {1}',
+                    msg = 'ln' if hardlink else ('ln -s' if link else 'cp')
+
+                    self._log.info(u'{2} {0} {1}',
                                    util.displayable_path(original),
-                                   util.displayable_path(converted))
+                                   util.displayable_path(converted),
+                                   msg)
                 else:
                     # No transcoding necessary.
-                    self._log.info(u'Copying {0}',
-                                   util.displayable_path(item.path))
-                    util.copy(original, converted)
+                    msg = 'Hardlinking' if hardlink \
+                        else ('Linking' if link else 'Copying')
+
+                    self._log.info(u'{1} {0}',
+                                   util.displayable_path(item.path),
+                                   msg)
+
+                    if hardlink:
+                        util.hardlink(original, converted)
+                    elif link:
+                        util.link(original, converted)
+                    else:
+                        util.copy(original, converted)
 
             if pretend:
                 continue
@@ -331,7 +355,7 @@ def convert_item(self, dest_dir, keep_new, path_formats, fmt,
                 item.read()
                 item.store()  # Store new path and audio data.
 
-            if self.config['embed']:
+            if self.config['embed'] and not linked:
                 album = item.get_album()
                 if album and album.artpath:
                     self._log.debug(u'embedding album art from {}',
@@ -346,7 +370,8 @@ def convert_item(self, dest_dir, keep_new, path_formats, fmt,
                 plugins.send('after_convert', item=item,
                              dest=converted, keepnew=False)
 
-    def copy_album_art(self, album, dest_dir, path_formats, pretend=False):
+    def copy_album_art(self, album, dest_dir, path_formats, pretend=False,
+                       link=False, hardlink=False):
         """Copies or converts the associated cover art of the album. Album must
         have at least one track.
         """
@@ -400,14 +425,26 @@ def copy_album_art(self, album, dest_dir, path_formats, pretend=False):
                 ArtResizer.shared.resize(maxwidth, album.artpath, dest)
         else:
             if pretend:
-                self._log.info(u'cp {0} {1}',
+                msg = 'ln' if hardlink else ('ln -s' if link else 'cp')
+
+                self._log.info(u'{2} {0} {1}',
                                util.displayable_path(album.artpath),
-                               util.displayable_path(dest))
+                               util.displayable_path(dest),
+                               msg)
             else:
-                self._log.info(u'Copying cover art from {0} to {1}',
+                msg = 'Hardlinking' if hardlink \
+                    else ('Linking' if link else 'Copying')
+
+                self._log.info(u'{2} cover art from {0} to {1}',
                                util.displayable_path(album.artpath),
-                               util.displayable_path(dest))
-                util.copy(album.artpath, dest)
+                               util.displayable_path(dest),
+                               msg)
+                if hardlink:
+                    util.hardlink(album.artpath, dest)
+                elif link:
+                    util.link(album.artpath, dest)
+                else:
+                    util.copy(album.artpath, dest)
 
     def convert_func(self, lib, opts, args):
         dest = opts.dest or self.config['dest'].get()
@@ -426,6 +463,16 @@ def convert_func(self, lib, opts, args):
         else:
             pretend = self.config['pretend'].get(bool)
 
+        if opts.hardlink is not None:
+            hardlink = opts.hardlink
+            link = False
+        elif opts.link is not None:
+            hardlink = False
+            link = opts.link
+        else:
+            hardlink = self.config['hardlink'].get(bool)
+            link = self.config['link'].get(bool)
+
         if opts.album:
             albums = lib.albums(ui.decargs(args))
             items = [i for a in albums for i in a.items()]
@@ -446,13 +493,16 @@ def convert_func(self, lib, opts, args):
 
         if opts.album and self.config['copy_album_art']:
             for album in albums:
-                self.copy_album_art(album, dest, path_formats, pretend)
+                self.copy_album_art(album, dest, path_formats, pretend,
+                                    link, hardlink)
 
         convert = [self.convert_item(dest,
                                      opts.keep_new,
                                      path_formats,
                                      fmt,
-                                     pretend)
+                                     pretend,
+                                     link,
+                                     hardlink)
                    for _ in range(threads)]
         pipe = util.pipeline.Pipeline([iter(items), convert])
         pipe.run_parallel()


Test patch: