Skip to content

Commit

Permalink
Introduce atomic move and write of file
Browse files Browse the repository at this point in the history
The idea of this changes is simple: let move file to some temporary name
inside distance folder, and after the file is already copy it renames to
expected name.

When someone tries to save anything it also moves file to trigger OS
level notification for change FS.

This commit also enforce that `beets.util.move` shouldn't be used to
move directories as it described in comment.

Thus, this is fixed beetbox#3849
  • Loading branch information
catap committed Sep 15, 2021
1 parent 7548820 commit 90f2259
Show file tree
Hide file tree
Showing 2 changed files with 25 additions and 9 deletions.
2 changes: 2 additions & 0 deletions beets/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,8 @@ def write(self, path=None, tags=None, id3v23=None):
mediafile.update(item_tags)
try:
mediafile.save()
# Move file to trigger OS event if some processes subscribed to it
util.move(path, path, force=True)
except UnreadableFileError as exc:
raise WriteError(self.path, exc)

Expand Down
32 changes: 23 additions & 9 deletions beets/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import errno
import locale
import re
import tempfile
import shutil
import fnmatch
import functools
Expand Down Expand Up @@ -474,32 +475,45 @@ def copy(path, dest, replace=False):
traceback.format_exc())


def move(path, dest, replace=False):
def move(path, dest, replace=False, force=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
filesystems (or the rename otherwise fails), a copy is attempted
instead, in which case metadata will *not* be preserved. Paths are
translated to system paths.
`path` is the same as `dest` unless `force` is True. If the paths are
on different filesystems (or the rename otherwise fails), a copy
is attempted instead, in which case metadata will *not* be preserved.
Paths are translated to system paths.
"""
if samefile(path, dest):
if os.path.isdir(path):
raise FilesystemError(u'source is directory', 'move', (path, dest))
if samefile(path, dest) and not force:
return
path = syspath(path)
dest = syspath(dest)
if os.path.exists(dest) and not replace:
if os.path.exists(dest) and not replace and not force:
raise FilesystemError(u'file exists', 'rename', (path, dest))

# First, try renaming the file.
try:
os.rename(path, dest)
os.replace(path, dest)
except OSError:
# Otherwise, copy and delete the original.
if os.path.isdir(dest):
dest = os.path.join(dest, os.path.basename(path))
tmp = tempfile.mktemp(suffix='.beets',
prefix=as_string(b'.' + os.path.basename(dest)),
dir=as_string(os.path.dirname(dest)))
tmp = syspath(tmp)
try:
shutil.copyfile(path, dest)
shutil.copyfile(path, tmp)
os.replace(tmp, dest)
tmp = None
os.remove(path)
except (OSError, IOError) as exc:
raise FilesystemError(exc, 'move', (path, dest),
traceback.format_exc())
finally:
if tmp is not None:
os.remove(tmp)


def link(path, dest, replace=False):
Expand Down

0 comments on commit 90f2259

Please sign in to comment.