diff --git a/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs b/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs index 23eeef97c..f5724489c 100644 --- a/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs +++ b/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs @@ -19,5 +19,6 @@ namespace NzbDrone.Api.Config public String ChownGroup { get; set; } public Boolean SkipFreeSpaceCheckWhenImporting { get; set; } + public Boolean CopyUsingHardlinks { get; set; } } } diff --git a/src/NzbDrone.Common.Test/DiskProviderTests/DiskProviderFixtureBase.cs b/src/NzbDrone.Common.Test/DiskProviderTests/DiskProviderFixtureBase.cs index dd463e1ac..6f36cf59b 100644 --- a/src/NzbDrone.Common.Test/DiskProviderTests/DiskProviderFixtureBase.cs +++ b/src/NzbDrone.Common.Test/DiskProviderTests/DiskProviderFixtureBase.cs @@ -162,6 +162,72 @@ namespace NzbDrone.Common.Test.DiskProviderTests Directory.Exists(sourceDir).Should().BeFalse(); } + [Test] + public void should_be_able_to_hardlink_file() + { + var sourceDir = GetTempFilePath(); + var source = Path.Combine(sourceDir, "test.txt"); + var destination = Path.Combine(sourceDir, "destination.txt"); + + Directory.CreateDirectory(sourceDir); + + Subject.WriteAllText(source, "SourceFile"); + + var result = Subject.TransferFile(source, destination, TransferMode.HardLink); + + result.Should().Be(TransferMode.HardLink); + + File.AppendAllText(source, "Test"); + File.ReadAllText(destination).Should().Be("SourceFileTest"); + } + + private void DoHardLinkRename(FileShare fileShare) + { + var sourceDir = GetTempFilePath(); + var source = Path.Combine(sourceDir, "test.txt"); + var destination = Path.Combine(sourceDir, "destination.txt"); + var rename = Path.Combine(sourceDir, "rename.txt"); + + Directory.CreateDirectory(sourceDir); + + Subject.WriteAllText(source, "SourceFile"); + + Subject.TransferFile(source, destination, TransferMode.HardLink); + + using (var stream = new FileStream(source, FileMode.Open, FileAccess.Read, fileShare)) + { + stream.ReadByte(); + + Subject.MoveFile(destination, rename); + + stream.ReadByte(); + } + + File.Exists(rename).Should().BeTrue(); + File.Exists(destination).Should().BeFalse(); + + File.AppendAllText(source, "Test"); + File.ReadAllText(rename).Should().Be("SourceFileTest"); + } + + [Test] + public void should_be_able_to_rename_open_hardlinks_with_fileshare_delete() + { + DoHardLinkRename(FileShare.Delete); + } + + [Test] + public void should_not_be_able_to_rename_open_hardlinks_with_fileshare_none() + { + Assert.Throws(() => DoHardLinkRename(FileShare.None)); + } + + [Test] + public void should_not_be_able_to_rename_open_hardlinks_with_fileshare_write() + { + Assert.Throws(() => DoHardLinkRename(FileShare.Read)); + } + [Test] public void empty_folder_should_return_folder_modified_date() { diff --git a/src/NzbDrone.Common/Disk/DiskProviderBase.cs b/src/NzbDrone.Common/Disk/DiskProviderBase.cs index 8e8fbe0a9..e1ef09c0f 100644 --- a/src/NzbDrone.Common/Disk/DiskProviderBase.cs +++ b/src/NzbDrone.Common/Disk/DiskProviderBase.cs @@ -13,12 +13,6 @@ namespace NzbDrone.Common.Disk { public abstract class DiskProviderBase : IDiskProvider { - enum TransferAction - { - Copy, - Move - } - private static readonly Logger Logger = NzbDroneLogger.GetLogger(); public abstract long? GetAvailableSpace(string path); @@ -152,7 +146,7 @@ namespace NzbDrone.Common.Disk Ensure.That(source, () => source).IsValidPath(); Ensure.That(destination, () => destination).IsValidPath(); - TransferFolder(source, destination, TransferAction.Copy); + TransferFolder(source, destination, TransferMode.Copy); } public void MoveFolder(string source, string destination) @@ -162,7 +156,7 @@ namespace NzbDrone.Common.Disk try { - TransferFolder(source, destination, TransferAction.Move); + TransferFolder(source, destination, TransferMode.Move); DeleteFolder(source, true); } catch (Exception e) @@ -173,15 +167,15 @@ namespace NzbDrone.Common.Disk } } - private void TransferFolder(string source, string target, TransferAction transferAction) + public void TransferFolder(string source, string destination, TransferMode mode) { Ensure.That(source, () => source).IsValidPath(); - Ensure.That(target, () => target).IsValidPath(); + Ensure.That(destination, () => destination).IsValidPath(); - Logger.ProgressDebug("{0} {1} -> {2}", transferAction, source, target); + Logger.ProgressDebug("{0} {1} -> {2}", mode, source, destination); var sourceFolder = new DirectoryInfo(source); - var targetFolder = new DirectoryInfo(target); + var targetFolder = new DirectoryInfo(destination); if (!targetFolder.Exists) { @@ -190,28 +184,16 @@ namespace NzbDrone.Common.Disk foreach (var subDir in sourceFolder.GetDirectories()) { - TransferFolder(subDir.FullName, Path.Combine(target, subDir.Name), transferAction); + TransferFolder(subDir.FullName, Path.Combine(destination, subDir.Name), mode); } foreach (var sourceFile in sourceFolder.GetFiles("*.*", SearchOption.TopDirectoryOnly)) { - var destFile = Path.Combine(target, sourceFile.Name); + var destFile = Path.Combine(destination, sourceFile.Name); - Logger.ProgressDebug("{0} {1} -> {2}", transferAction, sourceFile, destFile); + Logger.ProgressDebug("{0} {1} -> {2}", mode, sourceFile, destFile); - switch (transferAction) - { - case TransferAction.Copy: - { - sourceFile.CopyTo(destFile, true); - break; - } - case TransferAction.Move: - { - MoveFile(sourceFile.FullName, destFile, true); - break; - } - } + TransferFile(sourceFile.FullName, destFile, mode, true); } } @@ -227,19 +209,15 @@ namespace NzbDrone.Common.Disk public void CopyFile(string source, string destination, bool overwrite = false) { - Ensure.That(source, () => source).IsValidPath(); - Ensure.That(destination, () => destination).IsValidPath(); - - if (source.PathEquals(destination)) - { - Logger.Warn("Source and destination can't be the same {0}", source); - return; - } - - File.Copy(source, destination, overwrite); + TransferFile(source, destination, TransferMode.Copy, overwrite); } public void MoveFile(string source, string destination, bool overwrite = false) + { + TransferFile(source, destination, TransferMode.Move, overwrite); + } + + public TransferMode TransferFile(string source, string destination, TransferMode mode, bool overwrite) { Ensure.That(source, () => source).IsValidPath(); Ensure.That(destination, () => destination).IsValidPath(); @@ -247,7 +225,7 @@ namespace NzbDrone.Common.Disk if (source.PathEquals(destination)) { Logger.Warn("Source and destination can't be the same {0}", source); - return; + return TransferMode.None; } if (FileExists(destination) && overwrite) @@ -255,10 +233,37 @@ namespace NzbDrone.Common.Disk DeleteFile(destination); } - RemoveReadOnly(source); - File.Move(source, destination); + if (mode.HasFlag(TransferMode.HardLink)) + { + bool createdHardlink = TryCreateHardLink(source, destination); + if (createdHardlink) + { + return TransferMode.HardLink; + } + else if (!mode.HasFlag(TransferMode.Copy)) + { + throw new IOException("Hardlinking from '" + source + "' to '" + destination + "' failed."); + } + } + + if (mode.HasFlag(TransferMode.Copy)) + { + File.Copy(source, destination, overwrite); + return TransferMode.Copy; + } + + if (mode.HasFlag(TransferMode.Move)) + { + RemoveReadOnly(source); + File.Move(source, destination); + return TransferMode.Move; + } + + return TransferMode.None; } + public abstract bool TryCreateHardLink(string source, string destination); + public void DeleteFolder(string path, bool recursive) { Ensure.That(path, () => path).IsValidPath(); diff --git a/src/NzbDrone.Common/Disk/IDiskProvider.cs b/src/NzbDrone.Common/Disk/IDiskProvider.cs index 1912b02ee..cc9934019 100644 --- a/src/NzbDrone.Common/Disk/IDiskProvider.cs +++ b/src/NzbDrone.Common/Disk/IDiskProvider.cs @@ -25,9 +25,12 @@ namespace NzbDrone.Common.Disk void CreateFolder(string path); void CopyFolder(string source, string destination); void MoveFolder(string source, string destination); + void TransferFolder(string source, string destination, TransferMode transferMode); void DeleteFile(string path); void CopyFile(string source, string destination, bool overwrite = false); void MoveFile(string source, string destination, bool overwrite = false); + TransferMode TransferFile(string source, string destination, TransferMode transferMode, bool overwrite = false); + bool TryCreateHardLink(string source, string destination); void DeleteFolder(string path, bool recursive); string ReadAllText(string filePath); void WriteAllText(string filename, string contents); diff --git a/src/NzbDrone.Common/Disk/TransferMode.cs b/src/NzbDrone.Common/Disk/TransferMode.cs new file mode 100644 index 000000000..7b03db836 --- /dev/null +++ b/src/NzbDrone.Common/Disk/TransferMode.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Common.Disk +{ + [Flags] + public enum TransferMode + { + None = 0, + + Move = 1, + Copy = 2, + HardLink = 4, + + HardLinkOrCopy = Copy | HardLink + } +} diff --git a/src/NzbDrone.Common/NzbDrone.Common.csproj b/src/NzbDrone.Common/NzbDrone.Common.csproj index fc8d9470e..a92c863f5 100644 --- a/src/NzbDrone.Common/NzbDrone.Common.csproj +++ b/src/NzbDrone.Common/NzbDrone.Common.csproj @@ -73,6 +73,7 @@ + diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 8293d066a..39766b4bb 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -212,6 +212,13 @@ namespace NzbDrone.Core.Configuration set { SetValue("SkipFreeSpaceCheckWhenImporting", value); } } + public Boolean CopyUsingHardlinks + { + get { return GetValueBoolean("CopyUsingHardlinks", true); } + + set { SetValue("CopyUsingHardlinks", value); } + } + public Boolean SetPermissionsLinux { get { return GetValueBoolean("SetPermissionsLinux", false); } diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index 5dd81fc4b..93d298cfd 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -36,6 +36,7 @@ namespace NzbDrone.Core.Configuration Boolean CreateEmptySeriesFolders { get; set; } FileDateType FileDate { get; set; } Boolean SkipFreeSpaceCheckWhenImporting { get; set; } + Boolean CopyUsingHardlinks { get; set; } //Permissions (Media Management) Boolean SetPermissionsLinux { get; set; } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs b/src/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs index 0f0d39d98..beefa21ff 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs @@ -28,6 +28,7 @@ namespace NzbDrone.Core.MediaFiles private readonly IBuildFileNames _buildFileNames; private readonly IDiskProvider _diskProvider; private readonly IMediaFileAttributeService _mediaFileAttributeService; + private readonly IConfigService _configService; private readonly Logger _logger; public EpisodeFileMovingService(IEpisodeService episodeService, @@ -35,6 +36,7 @@ namespace NzbDrone.Core.MediaFiles IBuildFileNames buildFileNames, IDiskProvider diskProvider, IMediaFileAttributeService mediaFileAttributeService, + IConfigService configService, Logger logger) { _episodeService = episodeService; @@ -42,6 +44,7 @@ namespace NzbDrone.Core.MediaFiles _buildFileNames = buildFileNames; _diskProvider = diskProvider; _mediaFileAttributeService = mediaFileAttributeService; + _configService = configService; _logger = logger; } @@ -53,7 +56,7 @@ namespace NzbDrone.Core.MediaFiles _logger.Debug("Renaming episode file: {0} to {1}", episodeFile, filePath); - return TransferFile(episodeFile, series, episodes, filePath, false); + return TransferFile(episodeFile, series, episodes, filePath, TransferMode.Move); } public EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode) @@ -63,7 +66,7 @@ namespace NzbDrone.Core.MediaFiles _logger.Debug("Moving episode file: {0} to {1}", episodeFile, filePath); - return TransferFile(episodeFile, localEpisode.Series, localEpisode.Episodes, filePath, false); + return TransferFile(episodeFile, localEpisode.Series, localEpisode.Episodes, filePath, TransferMode.Move); } public EpisodeFile CopyEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode) @@ -73,10 +76,17 @@ namespace NzbDrone.Core.MediaFiles _logger.Debug("Copying episode file: {0} to {1}", episodeFile, filePath); - return TransferFile(episodeFile, localEpisode.Series, localEpisode.Episodes, filePath, true); + if (_configService.CopyUsingHardlinks) + { + return TransferFile(episodeFile, localEpisode.Series, localEpisode.Episodes, filePath, TransferMode.HardLinkOrCopy); + } + else + { + return TransferFile(episodeFile, localEpisode.Series, localEpisode.Episodes, filePath, TransferMode.Copy); + } } - private EpisodeFile TransferFile(EpisodeFile episodeFile, Series series, List episodes, String destinationFilename, Boolean copyOnly) + private EpisodeFile TransferFile(EpisodeFile episodeFile, Series series, List episodes, string destinationFilename, TransferMode mode) { Ensure.That(episodeFile, () => episodeFile).IsNotNull(); Ensure.That(series,() => series).IsNotNull(); @@ -115,16 +125,8 @@ namespace NzbDrone.Core.MediaFiles } } - if (copyOnly) - { - _logger.Debug("Copying [{0}] > [{1}]", episodeFilePath, destinationFilename); - _diskProvider.CopyFile(episodeFilePath, destinationFilename); - } - else - { - _logger.Debug("Moving [{0}] > [{1}]", episodeFilePath, destinationFilename); - _diskProvider.MoveFile(episodeFilePath, destinationFilename); - } + _logger.Debug("{0} [{1}] > [{2}]", mode, episodeFilePath, destinationFilename); + _diskProvider.TransferFile(episodeFilePath, destinationFilename, mode); episodeFile.RelativePath = series.Path.GetRelativePath(destinationFilename); diff --git a/src/NzbDrone.Mono/DiskProvider.cs b/src/NzbDrone.Mono/DiskProvider.cs index 0eac87104..5610197a3 100644 --- a/src/NzbDrone.Mono/DiskProvider.cs +++ b/src/NzbDrone.Mono/DiskProvider.cs @@ -7,6 +7,7 @@ using NzbDrone.Common; using NzbDrone.Common.Disk; using NzbDrone.Common.EnsureThat; using NzbDrone.Common.Instrumentation; +using Mono.Unix; namespace NzbDrone.Mono { @@ -151,5 +152,18 @@ namespace NzbDrone.Mono .OrderByDescending(drive => drive.Name.Length) .FirstOrDefault(); } + + public override bool TryCreateHardLink(string source, string destination) + { + try + { + UnixFileSystemInfo.GetFileSystemEntry(source).CreateLink(destination); + return true; + } + catch + { + return false; + } + } } } diff --git a/src/NzbDrone.Windows/DiskProvider.cs b/src/NzbDrone.Windows/DiskProvider.cs index db6a02304..f4c13c344 100644 --- a/src/NzbDrone.Windows/DiskProvider.cs +++ b/src/NzbDrone.Windows/DiskProvider.cs @@ -19,6 +19,10 @@ namespace NzbDrone.Windows out ulong lpTotalNumberOfBytes, out ulong lpTotalNumberOfFreeBytes); + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + [return: MarshalAs(UnmanagedType.Bool)] + static extern bool CreateHardLink(string lpFileName, string lpExistingFileName, IntPtr lpSecurityAttributes); + public override long? GetAvailableSpace(string path) { Ensure.That(path, () => path).IsValidPath(); @@ -98,5 +102,18 @@ namespace NzbDrone.Windows return 0; } + + + public override bool TryCreateHardLink(string source, string destination) + { + try + { + return CreateHardLink(destination, source, IntPtr.Zero); + } + catch + { + return false; + } + } } } diff --git a/src/UI/Settings/MediaManagement/Sorting/SortingViewTemplate.hbs b/src/UI/Settings/MediaManagement/Sorting/SortingViewTemplate.hbs index c137a1de3..b490565de 100644 --- a/src/UI/Settings/MediaManagement/Sorting/SortingViewTemplate.hbs +++ b/src/UI/Settings/MediaManagement/Sorting/SortingViewTemplate.hbs @@ -25,10 +25,10 @@ -{{#if_mono}}
Importing +{{#if_mono}}
@@ -51,5 +51,29 @@
-
{{/if_mono}} + +
+ + +
+
+
+
+