Add option for multiple ignored root directories (#390)

This commit is contained in:
Flaminel
2025-12-20 17:04:36 +02:00
committed by GitHub
parent 4ceff127a7
commit 58a72cef0f
26 changed files with 1244 additions and 56 deletions

View File

@@ -44,8 +44,8 @@ public static class ServicesDI
.AddScoped<IDownloadHunter, DownloadHunter>()
.AddScoped<IFilenameEvaluator, FilenameEvaluator>()
.AddScoped<IHardLinkFileService, HardLinkFileService>()
.AddScoped<UnixHardLinkFileService>()
.AddScoped<WindowsHardLinkFileService>()
.AddScoped<IUnixHardLinkFileService, UnixHardLinkFileService>()
.AddScoped<IWindowsHardLinkFileService, WindowsHardLinkFileService>()
.AddScoped<IArrQueueIterator, ArrQueueIterator>()
.AddScoped<IDownloadServiceFactory, DownloadServiceFactory>()
.AddScoped<IStriker, Striker>()

View File

@@ -24,7 +24,7 @@ public sealed record UpdateDownloadCleanerConfigRequest
public bool UnlinkedUseTag { get; init; }
public string UnlinkedIgnoredRootDir { get; init; } = string.Empty;
public List<string> UnlinkedIgnoredRootDirs { get; init; } = [];
public List<string> UnlinkedCategories { get; init; } = [];

View File

@@ -80,7 +80,7 @@ public sealed class DownloadCleanerConfigController : ControllerBase
oldConfig.UnlinkedEnabled = newConfigDto.UnlinkedEnabled;
oldConfig.UnlinkedTargetCategory = newConfigDto.UnlinkedTargetCategory;
oldConfig.UnlinkedUseTag = newConfigDto.UnlinkedUseTag;
oldConfig.UnlinkedIgnoredRootDir = newConfigDto.UnlinkedIgnoredRootDir;
oldConfig.UnlinkedIgnoredRootDirs = newConfigDto.UnlinkedIgnoredRootDirs;
oldConfig.UnlinkedCategories = newConfigDto.UnlinkedCategories;
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
oldConfig.Categories.Clear();

View File

@@ -716,7 +716,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked",
UnlinkedIgnoredRootDir = "/ignore"
UnlinkedIgnoredRootDirs = ["/ignore"]
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
@@ -744,7 +744,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Assert
_fixture.HardLinkFileService.Verify(
x => x.PopulateFileCounts("/ignore"),
x => x.PopulateFileCounts(It.Is<IEnumerable<string>>(dirs => dirs.Contains("/ignore"))),
Times.Once);
}

View File

@@ -900,7 +900,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
Id = Guid.NewGuid(),
UnlinkedUseTag = false,
UnlinkedTargetCategory = "unlinked",
UnlinkedIgnoredRootDir = "/ignore"
UnlinkedIgnoredRootDirs = ["/ignore"]
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
@@ -925,7 +925,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Assert
_fixture.HardLinkFileService.Verify(
x => x.PopulateFileCounts("/ignore"),
x => x.PopulateFileCounts(It.Is<IEnumerable<string>>(dirs => dirs.Contains("/ignore"))),
Times.Once);
}

View File

@@ -787,7 +787,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked",
UnlinkedIgnoredRootDir = "/ignore"
UnlinkedIgnoredRootDirs = ["/ignore"]
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
@@ -813,7 +813,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Assert
_fixture.HardLinkFileService.Verify(
x => x.PopulateFileCounts("/ignore"),
x => x.PopulateFileCounts(It.Is<IEnumerable<string>>(dirs => dirs.Contains("/ignore"))),
Times.Once);
}

View File

@@ -639,7 +639,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked",
UnlinkedIgnoredRootDir = "/ignore"
UnlinkedIgnoredRootDirs = ["/ignore"]
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
@@ -666,7 +666,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Assert
_fixture.HardLinkFileService.Verify(
x => x.PopulateFileCounts("/ignore"),
x => x.PopulateFileCounts(It.Is<IEnumerable<string>>(dirs => dirs.Contains("/ignore"))),
Times.Once);
}

View File

@@ -65,9 +65,9 @@ public partial class DelugeService
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
if (!string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir))
if (downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0)
{
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDir);
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDirs);
}
foreach (DelugeItemWrapper torrent in downloads.Cast<DelugeItemWrapper>())
@@ -105,7 +105,7 @@ public partial class DelugeService
}
long hardlinkCount = _hardLinkFileService
.GetHardLinkCount(filePath, !string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir));
.GetHardLinkCount(filePath, downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{

View File

@@ -89,9 +89,9 @@ public partial class QBitService
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
if (!string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir))
if (downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0)
{
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDir);
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDirs);
}
foreach (QBitItemWrapper torrent in downloads.Cast<QBitItemWrapper>())
@@ -131,7 +131,7 @@ public partial class QBitService
continue;
}
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, !string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir));
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{

View File

@@ -59,9 +59,9 @@ public partial class TransmissionService
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
if (!string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir))
if (downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0)
{
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDir);
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDirs);
}
foreach (TransmissionItemWrapper torrent in downloads.Cast<TransmissionItemWrapper>())
@@ -95,7 +95,7 @@ public partial class TransmissionService
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.Info.DownloadDir, file.Name).Split(['\\', '/']));
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, !string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir));
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{

View File

@@ -55,9 +55,9 @@ public partial class UTorrentService
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
if (!string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir))
if (downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0)
{
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDir);
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDirs);
}
foreach (UTorrentItemWrapper torrent in downloads.Cast<UTorrentItemWrapper>())
@@ -86,7 +86,7 @@ public partial class UTorrentService
}
long hardlinkCount = _hardLinkFileService
.GetHardLinkCount(filePath, !string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir));
.GetHardLinkCount(filePath, downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{

View File

@@ -6,13 +6,13 @@ namespace Cleanuparr.Infrastructure.Features.Files;
public class HardLinkFileService : IHardLinkFileService
{
private readonly ILogger<HardLinkFileService> _logger;
private readonly UnixHardLinkFileService _unixHardLinkFileService;
private readonly WindowsHardLinkFileService _windowsHardLinkFileService;
private readonly IUnixHardLinkFileService _unixHardLinkFileService;
private readonly IWindowsHardLinkFileService _windowsHardLinkFileService;
public HardLinkFileService(
ILogger<HardLinkFileService> logger,
UnixHardLinkFileService unixHardLinkFileService,
WindowsHardLinkFileService windowsHardLinkFileService
IUnixHardLinkFileService unixHardLinkFileService,
IWindowsHardLinkFileService windowsHardLinkFileService
)
{
_logger = logger;
@@ -23,16 +23,24 @@ public class HardLinkFileService : IHardLinkFileService
public void PopulateFileCounts(string directoryPath)
{
_logger.LogTrace("populating file counts from {dir}", directoryPath);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
_windowsHardLinkFileService.PopulateFileCounts(directoryPath);
return;
}
_unixHardLinkFileService.PopulateFileCounts(directoryPath);
}
public void PopulateFileCounts(IEnumerable<string> directoryPaths)
{
foreach (var directoryPath in directoryPaths.Where(d => !string.IsNullOrEmpty(d)))
{
PopulateFileCounts(directoryPath);
}
}
public long GetHardLinkCount(string filePath, bool ignoreRootDir)
{
if (!File.Exists(filePath))

View File

@@ -8,6 +8,12 @@ public interface IHardLinkFileService
/// </summary>
/// <param name="directoryPath">The root directory where to search for hardlinks.</param>
void PopulateFileCounts(string directoryPath);
/// <summary>
/// Populates the inode counts for Unix and the file index counts for Windows from multiple directories.
/// </summary>
/// <param name="directoryPaths">The root directories where to search for hardlinks.</param>
void PopulateFileCounts(IEnumerable<string> directoryPaths);
/// <summary>
/// Get the hardlink count of a file.

View File

@@ -0,0 +1,19 @@
namespace Cleanuparr.Infrastructure.Features.Files;
public interface ISpecificFileService
{
/// <summary>
/// Populates the inode counts for Unix and the file index counts for Windows.
/// Needs to be called before <see cref="GetHardLinkCount"/> to populate the inode counts.
/// </summary>
/// <param name="directoryPath">The root directory where to search for hardlinks.</param>
void PopulateFileCounts(string directoryPath);
/// <summary>
/// Get the hardlink count of a file.
/// </summary>
/// <param name="filePath">File path.</param>
/// <param name="ignoreRootDir">Whether to ignore hardlinks found in the same root dir.</param>
/// <returns>-1 on error, 0 if there are no hardlinks and 1 otherwise.</returns>
long GetHardLinkCount(string filePath, bool ignoreRootDir);
}

View File

@@ -0,0 +1,5 @@
namespace Cleanuparr.Infrastructure.Features.Files;
public interface IUnixHardLinkFileService : ISpecificFileService
{
}

View File

@@ -0,0 +1,5 @@
namespace Cleanuparr.Infrastructure.Features.Files;
public interface IWindowsHardLinkFileService : ISpecificFileService
{
}

View File

@@ -1,10 +1,10 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Mono.Unix.Native;
namespace Cleanuparr.Infrastructure.Features.Files;
public class UnixHardLinkFileService : IHardLinkFileService, IDisposable
public class UnixHardLinkFileService : IUnixHardLinkFileService, IDisposable
{
private readonly ILogger<UnixHardLinkFileService> _logger;
private readonly ConcurrentDictionary<ulong, int> _inodeCounts = new();

View File

@@ -1,11 +1,11 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
using Microsoft.Win32.SafeHandles;
namespace Cleanuparr.Infrastructure.Features.Files;
public class WindowsHardLinkFileService : IHardLinkFileService, IDisposable
public class WindowsHardLinkFileService : IWindowsHardLinkFileService, IDisposable
{
private readonly ILogger<WindowsHardLinkFileService> _logger;
private readonly ConcurrentDictionary<ulong, int> _fileIndexCounts = new();

View File

@@ -224,7 +224,7 @@ public sealed class DownloadCleanerConfigTests
UnlinkedEnabled = true,
UnlinkedTargetCategory = "cleanuparr-unlinked",
UnlinkedCategories = ["movies"],
UnlinkedIgnoredRootDir = "/non/existent/directory"
UnlinkedIgnoredRootDirs = ["/non/existent/directory"]
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
@@ -241,7 +241,7 @@ public sealed class DownloadCleanerConfigTests
UnlinkedEnabled = true,
UnlinkedTargetCategory = "cleanuparr-unlinked",
UnlinkedCategories = ["movies"],
UnlinkedIgnoredRootDir = ""
UnlinkedIgnoredRootDirs = []
};
Should.NotThrow(() => config.Validate());

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class ChangeUnlinkedIgnoredRootDirType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "unlinked_ignored_root_dir",
table: "download_cleaner_configs",
newName: "unlinked_ignored_root_dirs");
migrationBuilder.Sql("""
UPDATE download_cleaner_configs
SET unlinked_ignored_root_dirs = CASE
WHEN unlinked_ignored_root_dirs IS NULL OR unlinked_ignored_root_dirs = '' THEN '[]'
ELSE '["' || unlinked_ignored_root_dirs || '"]'
END
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
UPDATE download_cleaner_configs
SET unlinked_ignored_root_dirs = CASE
WHEN unlinked_ignored_root_dirs = '[]' OR unlinked_ignored_root_dirs IS NULL THEN ''
ELSE SUBSTR(unlinked_ignored_root_dirs, 3, LENGTH(unlinked_ignored_root_dirs) - 4)
END
""");
migrationBuilder.RenameColumn(
name: "unlinked_ignored_root_dirs",
table: "download_cleaner_configs",
newName: "unlinked_ignored_root_dir");
}
}
}

View File

@@ -139,10 +139,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("unlinked_enabled");
b.Property<string>("UnlinkedIgnoredRootDir")
b.PrimitiveCollection<string>("UnlinkedIgnoredRootDirs")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_ignored_root_dir");
.HasColumnName("unlinked_ignored_root_dirs");
b.Property<string>("UnlinkedTargetCategory")
.IsRequired()

View File

@@ -32,7 +32,7 @@ public sealed record DownloadCleanerConfig : IJobConfig
public bool UnlinkedUseTag { get; set; }
public string UnlinkedIgnoredRootDir { get; set; } = string.Empty;
public List<string> UnlinkedIgnoredRootDirs { get; set; } = [];
public List<string> UnlinkedCategories { get; set; } = [];
@@ -87,9 +87,12 @@ public sealed record DownloadCleanerConfig : IJobConfig
throw new ValidationException("Empty unlinked category filter found");
}
if (!string.IsNullOrEmpty(UnlinkedIgnoredRootDir) && !Directory.Exists(UnlinkedIgnoredRootDir))
foreach (var dir in UnlinkedIgnoredRootDirs.Where(d => !string.IsNullOrEmpty(d)))
{
throw new ValidationException($"{UnlinkedIgnoredRootDir} root directory does not exist");
if (!Directory.Exists(dir))
{
throw new ValidationException($"{dir} root directory does not exist");
}
}
}
}

View File

@@ -333,17 +333,20 @@
</div>
</div>
<!-- Ignored Root Directory -->
<!-- Ignored Root Directories -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('unlinkedIgnoredRootDir')"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('unlinkedIgnoredRootDirs')"
title="Click for documentation"></i>
Ignored Root Directory
Ignored Root Directories
</label>
<div class="field-input">
<input type="text" pInputText formControlName="unlinkedIgnoredRootDir" placeholder="/path/to/directory" />
<small class="form-helper-text">Root directory to ignore when checking for unlinked downloads (used for cross-seed)</small>
<app-mobile-autocomplete
formControlName="unlinkedIgnoredRootDirs"
placeholder="Add directory path"
></app-mobile-autocomplete>
<small class="form-helper-text">Root directories to ignore when checking for unlinked downloads (used for cross-seed)</small>
</div>
</div>

View File

@@ -150,7 +150,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
unlinkedEnabled: [{ value: false, disabled: true }],
unlinkedTargetCategory: [{ value: 'cleanuparr-unlinked', disabled: true }, [Validators.required]],
unlinkedUseTag: [{ value: false, disabled: true }],
unlinkedIgnoredRootDir: [{ value: '', disabled: true }],
unlinkedIgnoredRootDirs: [{ value: [], disabled: true }],
unlinkedCategories: [{ value: [], disabled: true }]
}, { validators: [this.validateUnlinkedCategories, this.validateAtLeastOneFeature] });
@@ -341,7 +341,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
unlinkedEnabled: config.unlinkedEnabled,
unlinkedTargetCategory: config.unlinkedTargetCategory,
unlinkedUseTag: config.unlinkedUseTag,
unlinkedIgnoredRootDir: config.unlinkedIgnoredRootDir,
unlinkedIgnoredRootDirs: config.unlinkedIgnoredRootDirs || [],
unlinkedCategories: config.unlinkedCategories || []
});
@@ -624,7 +624,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
unlinkedEnabled: formValues.unlinkedEnabled,
unlinkedTargetCategory: formValues.unlinkedTargetCategory,
unlinkedUseTag: formValues.unlinkedUseTag,
unlinkedIgnoredRootDir: formValues.unlinkedIgnoredRootDir,
unlinkedIgnoredRootDirs: formValues.unlinkedIgnoredRootDirs || [],
unlinkedCategories: formValues.unlinkedCategories || []
};
@@ -682,7 +682,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
unlinkedEnabled: false,
unlinkedTargetCategory: 'cleanuparr-unlinked',
unlinkedUseTag: false,
unlinkedIgnoredRootDir: '',
unlinkedIgnoredRootDirs: [],
unlinkedCategories: []
});
@@ -793,7 +793,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
private updateUnlinkedControlsState(enabled: boolean): void {
const targetCategoryControl = this.downloadCleanerForm.get('unlinkedTargetCategory');
const useTagControl = this.downloadCleanerForm.get('unlinkedUseTag');
const ignoredRootDirControl = this.downloadCleanerForm.get('unlinkedIgnoredRootDir');
const ignoredRootDirsControl = this.downloadCleanerForm.get('unlinkedIgnoredRootDirs');
const categoriesControl = this.downloadCleanerForm.get('unlinkedCategories');
// Disable emitting events during bulk changes
@@ -803,13 +803,13 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
// Enable all unlinked controls
targetCategoryControl?.enable(options);
useTagControl?.enable(options);
ignoredRootDirControl?.enable(options);
ignoredRootDirsControl?.enable(options);
categoriesControl?.enable(options);
} else {
// Disable all unlinked controls
targetCategoryControl?.disable(options);
useTagControl?.disable(options);
ignoredRootDirControl?.disable(options);
ignoredRootDirsControl?.disable(options);
categoriesControl?.disable(options);
}
}

View File

@@ -13,7 +13,7 @@ export interface DownloadCleanerConfig {
unlinkedEnabled: boolean;
unlinkedTargetCategory: string;
unlinkedUseTag: boolean;
unlinkedIgnoredRootDir: string;
unlinkedIgnoredRootDirs: string[];
unlinkedCategories: string[];
}
@@ -56,6 +56,6 @@ export const defaultDownloadCleanerConfig: DownloadCleanerConfig = {
unlinkedEnabled: false,
unlinkedTargetCategory: 'cleanuparr-unlinked',
unlinkedUseTag: false,
unlinkedIgnoredRootDir: '',
unlinkedIgnoredRootDirs: [],
unlinkedCategories: []
};