diff --git a/code/Common/Configuration/DownloadCleaner/DownloadCleanerConfig.cs b/code/Common/Configuration/DownloadCleaner/DownloadCleanerConfig.cs index c9a0459..efd79f4 100644 --- a/code/Common/Configuration/DownloadCleaner/DownloadCleanerConfig.cs +++ b/code/Common/Configuration/DownloadCleaner/DownloadCleanerConfig.cs @@ -16,6 +16,9 @@ public sealed record DownloadCleanerConfig : IJobConfig [ConfigurationKeyName("NO_HARDLINKS_CATEGORY")] public string NoHardlinksCategory { get; init; } = ""; + + [ConfigurationKeyName("IGNORE_ROOT_DIR")] + public bool IgnoreRootDir { get; init; } [ConfigurationKeyName("HARDLINK_CATEGORIES")] public List? HardlinkCategories { get; init; } diff --git a/code/Infrastructure/Verticals/DownloadClient/QBittorrent/QBitService.cs b/code/Infrastructure/Verticals/DownloadClient/QBittorrent/QBitService.cs index d4ec298..0ffa788 100644 --- a/code/Infrastructure/Verticals/DownloadClient/QBittorrent/QBitService.cs +++ b/code/Infrastructure/Verticals/DownloadClient/QBittorrent/QBitService.cs @@ -300,6 +300,17 @@ public class QBitService : DownloadService, IQBitService public override async Task ChangeCategoryForNoHardlinksAsync(List downloads, HashSet excludedHashes) { + if (_downloadCleanerConfig.IgnoreRootDir) + { + // TODO call this only if Unix + downloads + .Cast() + .GroupBy(x => x.SavePath) + .Select(x => x.Key) + .ToList() + .ForEach(x => _hardlinkFileService.PopulateInodeCounts(x)); + } + // TODO account for cross-seed foreach (TorrentInfo download in downloads) { @@ -332,7 +343,7 @@ public class QBitService : DownloadService, IQBitService : download.SavePath, file.Name ); - ulong hardlinkCount = _hardlinkFileService.GetHardLinkCount(filePath); + ulong hardlinkCount = _hardlinkFileService.GetHardLinkCount(filePath, _downloadCleanerConfig.IgnoreRootDir); if (hardlinkCount is 0) { diff --git a/code/Infrastructure/Verticals/Files/HardlinkFileService.cs b/code/Infrastructure/Verticals/Files/HardlinkFileService.cs index 583e8b8..5d3bf85 100644 --- a/code/Infrastructure/Verticals/Files/HardlinkFileService.cs +++ b/code/Infrastructure/Verticals/Files/HardlinkFileService.cs @@ -1,4 +1,5 @@ -using System.Runtime.InteropServices; +using System.Collections.Concurrent; +using System.Runtime.InteropServices; using Microsoft.Extensions.Logging; using Microsoft.Win32.SafeHandles; using Mono.Unix.Native; @@ -8,13 +9,15 @@ namespace Infrastructure.Verticals.Files; public class HardlinkFileService : IHardlinkFileService { private readonly ILogger _logger; + // Track inode counts in the ignored directory (e.g., root directory) + private readonly ConcurrentDictionary _inodeCounts = new(); public HardlinkFileService(ILogger logger) { _logger = logger; } - public ulong GetHardLinkCount(string filePath) + public ulong GetHardLinkCount(string filePath, bool ignoreRootDir) { if (!File.Exists(filePath)) { @@ -28,7 +31,7 @@ public class HardlinkFileService : IHardlinkFileService return GetWindowsHardLinkCount(filePath); } - return GetUnixHardLinkCount(filePath); + return GetUnixHardLinkCount(filePath, ignoreRootDir); } private uint GetWindowsHardLinkCount(string filePath) @@ -71,20 +74,69 @@ public class HardlinkFileService : IHardlinkFileService public uint FileIndexLow; } - private ulong GetUnixHardLinkCount(string filePath) + // Call this first to populate inode counts from the directory you want to ignore + public void PopulateInodeCounts(string directoryPath) { try { - if (Syscall.stat(filePath, out Stat stat) == 0) + // Traverse all files and directories in the ignored path + foreach (var file in Directory.EnumerateFiles(directoryPath, "*", SearchOption.AllDirectories)) { - return stat.st_nlink; + AddInodeToCount(file); } + + foreach (var dir in Directory.EnumerateDirectories(directoryPath, "*", SearchOption.AllDirectories)) + { + AddInodeToCount(dir); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to populate inode counts from {dir}", directoryPath); + } + } + + private void AddInodeToCount(string path) + { + try + { + if (Syscall.stat(path, out Stat stat) == 0) + { + _inodeCounts.AddOrUpdate(stat.st_ino, 1, (_, count) => count + 1); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Couldn't stat {path} during inode counting", path); + } + } + + // Modified GetUnixHardLinkCount with ignore logic + public ulong GetUnixHardLinkCount(string filePath, bool ignoreRootDir) + { + try + { + if (Syscall.stat(filePath, out Stat stat) != 0) + return 0; + + if (!ignoreRootDir) + { + // Simple case: Just check if >1 hardlink exists + return stat.st_nlink > 1 ? stat.st_nlink : 0; + } + + // Adjusted case: Subtract links from the ignored directory + int linksInIgnoredDir = _inodeCounts.TryGetValue(stat.st_ino, out int count) + ? count + : 1; // Default to 1 if not found + + long adjustedCount = (long)stat.st_nlink - linksInIgnoredDir; + return (ulong)Math.Max(adjustedCount, 0); } catch (Exception exception) { - _logger.LogError(exception, "failed to stat file {file}", filePath); + _logger.LogError(exception, "Failed to stat file {file}", filePath); + return 0; } - - return 0; } } \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Files/IHardlinkFileService.cs b/code/Infrastructure/Verticals/Files/IHardlinkFileService.cs index 34ac2e5..4b50cc3 100644 --- a/code/Infrastructure/Verticals/Files/IHardlinkFileService.cs +++ b/code/Infrastructure/Verticals/Files/IHardlinkFileService.cs @@ -2,5 +2,6 @@ public interface IHardlinkFileService { - ulong GetHardLinkCount(string filePath); + void PopulateInodeCounts(string directoryPath); + ulong GetHardLinkCount(string filePath, bool ignoreRootDir); } \ No newline at end of file