Skip to content

Commit

Permalink
Merge pull request #2445 from jacobwgillespie/hardlinks
Browse files Browse the repository at this point in the history
Add option to hardlink when importing
  • Loading branch information
sampsyo committed Feb 21, 2017
2 parents 8ccdb1c + 4e77ef4 commit 41e5b9b
Show file tree
Hide file tree
Showing 11 changed files with 127 additions and 25 deletions.
1 change: 1 addition & 0 deletions beets/config_default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import:
copy: yes
move: no
link: no
hardlink: no
delete: no
resume: ask
incremental: no
Expand Down
19 changes: 13 additions & 6 deletions beets/importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,19 @@ def set_config(self, config):
iconfig['resume'] = False
iconfig['incremental'] = False

# Copy, move, and link are mutually exclusive.
# Copy, move, link, and hardlink are mutually exclusive.
if iconfig['move']:
iconfig['copy'] = False
iconfig['link'] = False
iconfig['hardlink'] = False
elif iconfig['link']:
iconfig['copy'] = False
iconfig['move'] = False
iconfig['hardlink'] = False
elif iconfig['hardlink']:
iconfig['copy'] = False
iconfig['move'] = False
iconfig['link'] = False

# Only delete when copying.
if not iconfig['copy']:
Expand Down Expand Up @@ -654,27 +660,27 @@ def align_album_level_fields(self):
item.update(changes)

def manipulate_files(self, move=False, copy=False, write=False,
link=False, session=None):
link=False, hardlink=False, session=None):
items = self.imported_items()
# Save the original paths of all items for deletion and pruning
# in the next step (finalization).
self.old_paths = [item.path for item in items]
for item in items:
if move or copy or link:
if move or copy or link or hardlink:
# In copy and link modes, treat re-imports specially:
# move in-library files. (Out-of-library files are
# copied/moved as usual).
old_path = item.path
if (copy or link) and self.replaced_items[item] and \
session.lib.directory in util.ancestry(old_path):
if (copy or link or hardlink) and self.replaced_items[item] \
and session.lib.directory in util.ancestry(old_path):
item.move()
# We moved the item, so remove the
# now-nonexistent file from old_paths.
self.old_paths.remove(old_path)
else:
# A normal import. Just copy files and keep track of
# old paths.
item.move(copy, link)
item.move(copy, link, hardlink)

if write and (self.apply or self.choice_flag == action.RETAG):
item.try_write()
Expand Down Expand Up @@ -1412,6 +1418,7 @@ def manipulate_files(session, task):
copy=session.config['copy'],
write=session.config['write'],
link=session.config['link'],
hardlink=session.config['hardlink'],
session=session,
)

Expand Down
26 changes: 17 additions & 9 deletions beets/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -663,7 +663,7 @@ def try_sync(self, write, move, with_album=True):

# Files themselves.

def move_file(self, dest, copy=False, link=False):
def move_file(self, dest, copy=False, link=False, hardlink=False):
"""Moves or copies the item's file, updating the path value if
the move succeeds. If a file exists at ``dest``, then it is
slightly modified to be unique.
Expand All @@ -678,6 +678,10 @@ def move_file(self, dest, copy=False, link=False):
util.link(self.path, dest)
plugins.send("item_linked", item=self, source=self.path,
destination=dest)
elif hardlink:
util.hardlink(self.path, dest)
plugins.send("item_hardlinked", item=self, source=self.path,
destination=dest)
else:
plugins.send("before_item_moved", item=self, source=self.path,
destination=dest)
Expand Down Expand Up @@ -730,15 +734,16 @@ def remove(self, delete=False, with_album=True):

self._db._memotable = {}

def move(self, copy=False, link=False, basedir=None, with_album=True,
store=True):
def move(self, copy=False, link=False, hardlink=False, basedir=None,
with_album=True, store=True):
"""Move the item to its designated location within the library
directory (provided by destination()). Subdirectories are
created as needed. If the operation succeeds, the item's path
field is updated to reflect the new location.
If `copy` is true, moving the file is copied rather than moved.
Similarly, `link` creates a symlink instead.
Similarly, `link` creates a symlink instead, and `hardlink`
creates a hardlink.
basedir overrides the library base directory for the
destination.
Expand All @@ -761,7 +766,7 @@ def move(self, copy=False, link=False, basedir=None, with_album=True,

# Perform the move and store the change.
old_path = self.path
self.move_file(dest, copy, link)
self.move_file(dest, copy, link, hardlink)
if store:
self.store()

Expand Down Expand Up @@ -979,7 +984,7 @@ def remove(self, delete=False, with_items=True):
for item in self.items():
item.remove(delete, False)

def move_art(self, copy=False, link=False):
def move_art(self, copy=False, link=False, hardlink=False):
"""Move or copy any existing album art so that it remains in the
same directory as the items.
"""
Expand All @@ -999,6 +1004,8 @@ def move_art(self, copy=False, link=False):
util.copy(old_art, new_art)
elif link:
util.link(old_art, new_art)
elif hardlink:
util.hardlink(old_art, new_art)
else:
util.move(old_art, new_art)
self.artpath = new_art
Expand All @@ -1008,7 +1015,8 @@ def move_art(self, copy=False, link=False):
util.prune_dirs(os.path.dirname(old_art),
self._db.directory)

def move(self, copy=False, link=False, basedir=None, store=True):
def move(self, copy=False, link=False, hardlink=False, basedir=None,
store=True):
"""Moves (or copies) all items to their destination. Any album
art moves along with them. basedir overrides the library base
directory for the destination. By default, the album is stored to the
Expand All @@ -1026,11 +1034,11 @@ def move(self, copy=False, link=False, basedir=None, store=True):
# Move items.
items = list(self.items())
for item in items:
item.move(copy, link, basedir=basedir, with_album=False,
item.move(copy, link, hardlink, basedir=basedir, with_album=False,
store=store)

# Move art.
self.move_art(copy, link)
self.move_art(copy, link, hardlink)
if store:
self.store()

Expand Down
36 changes: 30 additions & 6 deletions beets/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from __future__ import division, absolute_import, print_function
import os
import sys
import errno
import locale
import re
import shutil
Expand Down Expand Up @@ -477,16 +478,15 @@ def move(path, dest, replace=False):
def link(path, dest, replace=False):
"""Create a symbolic link from path to `dest`. Raises an OSError if
`dest` already exists, unless `replace` is True. Does nothing if
`path` == `dest`."""
if (samefile(path, dest)):
`path` == `dest`.
"""
if samefile(path, dest):
return

path = syspath(path)
dest = syspath(dest)
if os.path.exists(dest) and not replace:
if os.path.exists(syspath(dest)) and not replace:
raise FilesystemError(u'file exists', 'rename', (path, dest))
try:
os.symlink(path, dest)
os.symlink(syspath(path), syspath(dest))
except NotImplementedError:
# raised on python >= 3.2 and Windows versions before Vista
raise FilesystemError(u'OS does not support symbolic links.'
Expand All @@ -500,6 +500,30 @@ def link(path, dest, replace=False):
traceback.format_exc())


def hardlink(path, dest, replace=False):
"""Create a hard link from path to `dest`. Raises an OSError if
`dest` already exists, unless `replace` is True. Does nothing if
`path` == `dest`.
"""
if samefile(path, dest):
return

if os.path.exists(syspath(dest)) and not replace:
raise FilesystemError(u'file exists', 'rename', (path, dest))
try:
os.link(syspath(path), syspath(dest))
except NotImplementedError:
raise FilesystemError(u'OS does not support hard links.'
'link', (path, dest), traceback.format_exc())
except OSError as exc:
if exc.errno == errno.EXDEV:
raise FilesystemError(u'Cannot hard link across devices.'
'link', (path, dest), traceback.format_exc())
else:
raise FilesystemError(exc, 'link', (path, dest),
traceback.format_exc())


def unique_path(path):
"""Returns a version of ``path`` that does not exist on the
filesystem. Specifically, if ``path` itself already exists, then
Expand Down
3 changes: 2 additions & 1 deletion beetsplug/importadded.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def __init__(self):
register('before_item_moved', self.record_import_mtime)
register('item_copied', self.record_import_mtime)
register('item_linked', self.record_import_mtime)
register('item_hardlinked', self.record_import_mtime)
register('album_imported', self.update_album_times)
register('item_imported', self.update_item_times)
register('after_write', self.update_after_write_time)
Expand All @@ -51,7 +52,7 @@ def reimported_album(self, album):

def record_if_inplace(self, task, session):
if not (session.config['copy'] or session.config['move'] or
session.config['link']):
session.config['link'] or session.config['hardlink']):
self._log.debug(u"In place import detected, recording mtimes from "
u"source paths")
items = [task.item] \
Expand Down
4 changes: 4 additions & 0 deletions docs/dev/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ The events currently available are:
for a file.
Parameters: ``item``, ``source`` path, ``destination`` path

* `item_hardlinked`: called with an ``Item`` object whenever a hardlink is
created for a file.
Parameters: ``item``, ``source`` path, ``destination`` path

* `item_removed`: called with an ``Item`` object every time an item (singleton
or album's part) is removed from the library (even when its file is not
deleted from disk).
Expand Down
17 changes: 15 additions & 2 deletions docs/reference/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -433,15 +433,28 @@ link
~~~~

Either ``yes`` or ``no``, indicating whether to use symbolic links instead of
moving or copying files. (It conflicts with the ``move`` and ``copy``
options.) Defaults to ``no``.
moving or copying files. (It conflicts with the ``move``, ``copy`` and
``hardlink`` options.) Defaults to ``no``.

This option only works on platforms that support symbolic links: i.e., Unixes.
It will fail on Windows.

It's likely that you'll also want to set ``write`` to ``no`` if you use this
option to preserve the metadata on the linked files.

.. _hardlink:

hardlink
~~~~~~~~

Either ``yes`` or ``no``, indicating whether to use hard links instead of
moving or copying or symlinking files. (It conflicts with the ``move``,
``copy``, and ``link`` options.) Defaults to ``no``.

As with symbolic links (see :ref:`link`, above), this will not work on Windows
and you will want to set ``write`` to ``no``. Otherwise meatadata on the
original file will be modified.

resume
~~~~~~

Expand Down
1 change: 1 addition & 0 deletions test/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@

# OS feature test.
HAVE_SYMLINK = sys.platform != 'win32'
HAVE_HARDLINK = sys.platform != 'win32'


def item(lib=None):
Expand Down
21 changes: 21 additions & 0 deletions test/test_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,27 @@ def test_link_changes_path(self):
self.i.move(link=True)
self.assertEqual(self.i.path, util.normpath(self.dest))

@unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks")
def test_hardlink_arrives(self):
self.i.move(hardlink=True)
self.assertExists(self.dest)
s1 = os.stat(self.path)
s2 = os.stat(self.dest)
self.assertTrue(
(s1[stat.ST_INO], s1[stat.ST_DEV]) ==
(s2[stat.ST_INO], s2[stat.ST_DEV])
)

@unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks")
def test_hardlink_does_not_depart(self):
self.i.move(hardlink=True)
self.assertExists(self.path)

@unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks")
def test_hardlink_changes_path(self):
self.i.move(hardlink=True)
self.assertEqual(self.i.path, util.normpath(self.dest))


class HelperTest(_common.TestCase):
def test_ancestry_works_on_file(self):
Expand Down
1 change: 1 addition & 0 deletions test/test_importadded.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ def test_import_album_inplace_with_added_dates(self):
self.config['import']['copy'] = False
self.config['import']['move'] = False
self.config['import']['link'] = False
self.config['import']['hardlink'] = False
self.assertAlbumImport()

def test_import_album_with_preserved_mtimes(self):
Expand Down
23 changes: 22 additions & 1 deletion test/test_importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import shutil
import unicodedata
import sys
import stat
from six import StringIO
from tempfile import mkstemp
from zipfile import ZipFile
Expand Down Expand Up @@ -209,7 +210,8 @@ def _create_import_dir(self, count=3):

def _setup_import_session(self, import_dir=None, delete=False,
threaded=False, copy=True, singletons=False,
move=False, autotag=True, link=False):
move=False, autotag=True, link=False,
hardlink=False):
config['import']['copy'] = copy
config['import']['delete'] = delete
config['import']['timid'] = True
Expand All @@ -219,6 +221,7 @@ def _setup_import_session(self, import_dir=None, delete=False,
config['import']['autotag'] = autotag
config['import']['resume'] = False
config['import']['link'] = link
config['import']['hardlink'] = hardlink

self.importer = TestImportSession(
self.lib, loghandler=None, query=None,
Expand Down Expand Up @@ -353,6 +356,24 @@ def test_import_link_arrives(self):
mediafile.path
)

@unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks")
def test_import_hardlink_arrives(self):
config['import']['hardlink'] = True
self.importer.run()
for mediafile in self.import_media:
filename = os.path.join(
self.libdir,
b'Tag Artist', b'Tag Album',
util.bytestring_path('{0}.mp3'.format(mediafile.title))
)
self.assertExists(filename)
s1 = os.stat(mediafile.path)
s2 = os.stat(filename)
self.assertTrue(
(s1[stat.ST_INO], s1[stat.ST_DEV]) ==
(s2[stat.ST_INO], s2[stat.ST_DEV])
)


def create_archive(session):
(handle, path) = mkstemp(dir=py3_path(session.temp_dir))
Expand Down

0 comments on commit 41e5b9b

Please sign in to comment.