Add orphaned files cleanup (#618)

This commit is contained in:
Flaminel
2026-05-27 19:26:39 +03:00
committed by GitHub
parent 1ca935b62b
commit 8ccd93dc97
98 changed files with 7093 additions and 1266 deletions

View File

@@ -4,6 +4,7 @@ using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Infrastructure.Features.BlacklistSync;
using Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
@@ -47,6 +48,9 @@ public static class ServicesDI
.AddScoped<BlacklistSynchronizer>()
.AddScoped<MalwareBlocker>()
.AddScoped<DownloadCleaner>()
.AddScoped<ISeedingRulesCleanupService, SeedingRulesCleanupService>()
.AddScoped<IUnlinkedDownloadsService, UnlinkedDownloadsService>()
.AddScoped<IOrphanedFilesCleanupService, OrphanedFilesCleanupService>()
.AddScoped<Seeker>()
.AddScoped<CustomFormatScoreSyncer>()
.AddScoped<IQueueItemRemover, QueueItemRemover>()

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
public sealed record OrphanedFilesConfigRequest
{
public bool Enabled { get; init; }
public List<string> ScanDirectories { get; init; } = [];
[Required]
public string OrphanedDirectory { get; init; } = string.Empty;
public List<string> ExcludePatterns { get; init; } = [];
[Range(0, int.MaxValue)]
public int MinFileAgeHours { get; init; } = 24;
[Range(1, int.MaxValue)]
public int? PurgeAfterHours { get; init; }
}

View File

@@ -11,8 +11,4 @@ public sealed record UnlinkedConfigRequest
public List<string> IgnoredRootDirs { get; init; } = [];
public List<string> Categories { get; init; } = [];
public string? DownloadDirectorySource { get; init; }
public string? DownloadDirectoryTarget { get; init; }
}

View File

@@ -53,6 +53,7 @@ public sealed class DownloadCleanerConfigController : ControllerBase
var allUTorrentRules = await _dataContext.UTorrentSeedingRules.AsNoTracking().ToListAsync();
var allRTorrentRules = await _dataContext.RTorrentSeedingRules.AsNoTracking().ToListAsync();
var allUnlinkedConfigs = await _dataContext.UnlinkedConfigs.AsNoTracking().ToListAsync();
var allOrphanedFilesConfigs = await _dataContext.OrphanedFilesConfigs.AsNoTracking().ToListAsync();
var clients = new List<object>();
@@ -61,6 +62,7 @@ public sealed class DownloadCleanerConfigController : ControllerBase
var seedingRules = SeedingRuleHelper.FilterForClient(
client, allQBitRules, allDelugeRules, allTransmissionRules, allUTorrentRules, allRTorrentRules);
var unlinkedConfig = allUnlinkedConfigs.FirstOrDefault(u => u.DownloadClientConfigId == client.Id);
var orphanedFilesConfig = allOrphanedFilesConfigs.FirstOrDefault(o => o.DownloadClientConfigId == client.Id);
clients.Add(new
{
@@ -91,8 +93,17 @@ public sealed class DownloadCleanerConfigController : ControllerBase
useTag = unlinkedConfig.UseTag,
ignoredRootDirs = unlinkedConfig.IgnoredRootDirs,
categories = unlinkedConfig.Categories,
downloadDirectorySource = unlinkedConfig.DownloadDirectorySource,
downloadDirectoryTarget = unlinkedConfig.DownloadDirectoryTarget,
}
: null,
orphanedFilesConfig = orphanedFilesConfig is not null
? new
{
enabled = orphanedFilesConfig.Enabled,
scanDirectories = orphanedFilesConfig.ScanDirectories,
orphanedDirectory = orphanedFilesConfig.OrphanedDirectory,
excludePatterns = orphanedFilesConfig.ExcludePatterns,
minFileAgeHours = orphanedFilesConfig.MinFileAgeHours,
purgeAfterHours = orphanedFilesConfig.PurgeAfterHours,
}
: null,
});

View File

@@ -0,0 +1,123 @@
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
[ApiController]
[Route("api/orphaned-files-config")]
[Authorize]
public sealed class OrphanedFilesConfigController : ControllerBase
{
private readonly ILogger<OrphanedFilesConfigController> _logger;
private readonly DataContext _dataContext;
public OrphanedFilesConfigController(
ILogger<OrphanedFilesConfigController> logger,
DataContext dataContext)
{
_logger = logger;
_dataContext = dataContext;
}
[HttpGet("{downloadClientId}")]
public async Task<IActionResult> GetClientConfig(Guid downloadClientId)
{
await DataContext.Lock.WaitAsync();
try
{
var client = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
}
var config = await _dataContext.OrphanedFilesConfigs
.AsNoTracking()
.FirstOrDefaultAsync(c => c.DownloadClientConfigId == downloadClientId);
return Ok(config);
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("{downloadClientId}")]
public async Task<IActionResult> UpdateClientConfig(Guid downloadClientId, [FromBody] OrphanedFilesConfigRequest dto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
var client = await _dataContext.DownloadClients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == downloadClientId);
if (client is null)
{
return NotFound(new { Message = $"Download client with ID {downloadClientId} not found" });
}
var existing = await _dataContext.OrphanedFilesConfigs
.FirstOrDefaultAsync(c => c.DownloadClientConfigId == downloadClientId);
var candidate = (existing ?? new OrphanedFilesConfig { DownloadClientConfigId = downloadClientId }) with
{
Enabled = dto.Enabled,
ScanDirectories = dto.ScanDirectories,
OrphanedDirectory = dto.OrphanedDirectory,
ExcludePatterns = dto.ExcludePatterns,
MinFileAgeHours = dto.MinFileAgeHours,
PurgeAfterHours = dto.PurgeAfterHours,
};
var siblings = await _dataContext.OrphanedFilesConfigs
.AsNoTracking()
.Where(c => c.DownloadClientConfigId != downloadClientId)
.ToListAsync();
var otherDownloadClients = await _dataContext.DownloadClients
.AsNoTracking()
.Where(c => c.Id != downloadClientId)
.ToListAsync();
candidate.Validate(siblings, otherDownloadClients);
if (existing is null)
{
_dataContext.OrphanedFilesConfigs.Add(candidate);
}
else
{
existing.Enabled = candidate.Enabled;
existing.ScanDirectories = candidate.ScanDirectories;
existing.OrphanedDirectory = candidate.OrphanedDirectory;
existing.ExcludePatterns = candidate.ExcludePatterns;
existing.MinFileAgeHours = candidate.MinFileAgeHours;
existing.PurgeAfterHours = candidate.PurgeAfterHours;
}
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Updated orphaned files client config for client {ClientId}", downloadClientId);
return Ok(existing ?? candidate);
}
finally
{
DataContext.Lock.Release();
}
}
}

View File

@@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
@@ -46,11 +45,6 @@ public class UnlinkedConfigController : ControllerBase
return Ok(config);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve unlinked config for client {ClientId}", downloadClientId);
return StatusCode(500, new { Message = "Failed to retrieve unlinked config", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
@@ -94,8 +88,6 @@ public class UnlinkedConfigController : ControllerBase
existing.UseTag = dto.UseTag;
existing.IgnoredRootDirs = dto.IgnoredRootDirs;
existing.Categories = dto.Categories;
existing.DownloadDirectorySource = dto.DownloadDirectorySource;
existing.DownloadDirectoryTarget = dto.DownloadDirectoryTarget;
existing.Validate();
@@ -105,16 +97,6 @@ public class UnlinkedConfigController : ControllerBase
return Ok(existing);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for unlinked config update: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update unlinked config for client {ClientId}", downloadClientId);
return StatusCode(500, new { Message = "Failed to update unlinked config", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();

View File

@@ -27,6 +27,10 @@ public sealed record CreateDownloadClientRequest
public string? ExternalUrl { get; init; }
public string? DownloadDirectorySource { get; init; }
public string? DownloadDirectoryTarget { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Name))
@@ -66,5 +70,7 @@ public sealed record CreateDownloadClientRequest
Password = Password,
UrlBase = UrlBase,
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
DownloadDirectorySource = DownloadDirectorySource,
DownloadDirectoryTarget = DownloadDirectoryTarget,
};
}

View File

@@ -27,6 +27,10 @@ public sealed record UpdateDownloadClientRequest
public string? ExternalUrl { get; init; }
public string? DownloadDirectorySource { get; init; }
public string? DownloadDirectoryTarget { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Name))
@@ -61,5 +65,7 @@ public sealed record UpdateDownloadClientRequest
Password = Password.IsPlaceholder() ? existing.Password : Password,
UrlBase = UrlBase,
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
DownloadDirectorySource = DownloadDirectorySource,
DownloadDirectoryTarget = DownloadDirectoryTarget,
};
}

View File

@@ -66,6 +66,7 @@ public sealed class DownloadClientController : ControllerBase
newClient.Validate();
var clientConfig = newClient.ToEntity();
clientConfig.Validate();
_dataContext.DownloadClients.Add(clientConfig);
await _dataContext.SaveChangesAsync();
@@ -100,6 +101,7 @@ public sealed class DownloadClientController : ControllerBase
}
var clientToPersist = updatedClient.ApplyTo(existingClient);
clientToPersist.Validate();
_dataContext.Entry(existingClient).CurrentValues.SetValues(clientToPersist);
await _dataContext.SaveChangesAsync();

View File

@@ -61,8 +61,8 @@ public sealed class BlacklistSynchronizer : IHandler
string currentHash = ComputeHash(excludedFileNames);
await _dryRunInterceptor.InterceptAsync(SyncBlacklist, currentHash, excludedFileNames);
await _dryRunInterceptor.InterceptAsync(RemoveOldSyncDataAsync, currentHash);
await _dryRunInterceptor.InterceptAsync(() => SyncBlacklist(currentHash, excludedFileNames));
await _dryRunInterceptor.InterceptAsync(() => RemoveOldSyncDataAsync(currentHash));
_logger.LogDebug("Blacklist synchronization completed");
}

View File

@@ -47,19 +47,8 @@ public class BlacklistSynchronizerTests : IDisposable
_downloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
_dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
// Setup interceptor to execute the action with params using DynamicInvoke
_dryRunInterceptor.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(ci =>
{
var action = ci.ArgAt<Delegate>(0);
var parameters = ci.ArgAt<object[]>(1);
var result = action.DynamicInvoke(parameters);
if (result is Task task)
{
return task;
}
return Task.CompletedTask;
});
_dryRunInterceptor.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(ci => ci.ArgAt<Func<Task>>(0).Invoke());
// Setup FakeHttpMessageHandler for FileReader
_httpMessageHandler = new FakeHttpMessageHandler();
@@ -240,9 +229,8 @@ public class BlacklistSynchronizerTests : IDisposable
// Act
await _synchronizer.ExecuteAsync();
// Assert - Verify interceptor was called (with Delegate, not Func<object, object, Task>)
await _dryRunInterceptor.Received()
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>());
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>());
}
#endregion

View File

@@ -45,13 +45,8 @@ public class DelugeServiceFixture : IDisposable
ClientWrapper = Substitute.For<IDelugeClientWrapper>();
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
}
public DelugeService CreateSut(DownloadClientConfig? config = null)
@@ -107,13 +102,8 @@ public class DelugeServiceFixture : IDisposable
ClientWrapper = Substitute.For<IDelugeClientWrapper>();
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
}
public void Dispose()

View File

@@ -48,13 +48,8 @@ public class QBitServiceFixture : IDisposable
// Setup default behavior for DryRunInterceptor to execute actions directly
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
SetupSeedingRuleEvaluator();
}
@@ -114,13 +109,8 @@ public class QBitServiceFixture : IDisposable
// Re-setup default DryRunInterceptor behavior
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
SetupSeedingRuleEvaluator();
}

View File

@@ -46,13 +46,8 @@ public class RTorrentServiceFixture : IDisposable
ClientWrapper = Substitute.For<IRTorrentClientWrapper>();
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
}
public RTorrentService CreateSut(DownloadClientConfig? config = null)
@@ -108,13 +103,8 @@ public class RTorrentServiceFixture : IDisposable
ClientWrapper = Substitute.For<IRTorrentClientWrapper>();
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
}
public void Dispose()

View File

@@ -45,13 +45,8 @@ public class TransmissionServiceFixture : IDisposable
ClientWrapper = Substitute.For<ITransmissionClientWrapper>();
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
}
public TransmissionService CreateSut(DownloadClientConfig? config = null)
@@ -107,13 +102,8 @@ public class TransmissionServiceFixture : IDisposable
ClientWrapper = Substitute.For<ITransmissionClientWrapper>();
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
}
public void Dispose()

View File

@@ -45,13 +45,8 @@ public class UTorrentServiceFixture : IDisposable
ClientWrapper = Substitute.For<IUTorrentClientWrapper>();
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
}
public UTorrentService CreateSut(DownloadClientConfig? config = null)
@@ -107,13 +102,8 @@ public class UTorrentServiceFixture : IDisposable
ClientWrapper = Substitute.For<IUTorrentClientWrapper>();
DryRunInterceptor
.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(callInfo =>
{
var action = callInfo.ArgAt<Delegate>(0);
var parameters = callInfo.ArgAt<object[]>(1);
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(callInfo => callInfo.ArgAt<Func<Task>>(0).Invoke());
}
public void Dispose()

View File

@@ -67,9 +67,8 @@ public class QueueItemRemoverTests : IDisposable
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
dryRunInterceptor.IsDryRunEnabled().Returns(false);
// Setup interceptor for other uses (e.g., ArrClient deletion)
dryRunInterceptor
.InterceptAsync(default!, default!)
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(Task.CompletedTask);
_eventPublisher = new EventPublisher(

View File

@@ -0,0 +1,241 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Infrastructure.Tests.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs;
[Collection(JobHandlerCollection.Name)]
public sealed class DownloadCleanerOrphanedFilesTests : IDisposable
{
private readonly JobHandlerFixture _fixture;
private readonly ILogger<DownloadCleaner> _logger;
private readonly string _tempRoot;
public DownloadCleanerOrphanedFilesTests(JobHandlerFixture fixture)
{
_fixture = fixture;
_fixture.RecreateDataContext();
_fixture.ResetMocks();
_logger = _fixture.CreateLogger<DownloadCleaner>();
_tempRoot = Path.Combine(Path.GetTempPath(), "cleanuparr-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempRoot);
_fixture.DryRunInterceptor.When(x => x.Intercept(Arg.Any<Action>(), Arg.Any<string?>()))
.Do(ci => ((Action)ci.Args()[0]).Invoke());
}
public void Dispose()
{
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
GC.SuppressFinalize(this);
}
private DownloadCleaner CreateSut() => new(
_logger,
_fixture.DataContext,
_fixture.Cache,
_fixture.MessageBus,
_fixture.ArrClientFactory,
_fixture.ArrQueueIterator,
_fixture.DownloadServiceFactory,
_fixture.EventPublisher,
_fixture.TimeProvider,
_fixture.SeedingRulesService,
_fixture.UnlinkedService,
_fixture.OrphanedFilesService);
private async Task ExecuteWithTimeAdvance(DownloadCleaner sut)
{
var task = sut.ExecuteAsync();
_fixture.TimeProvider.Advance(TimeSpan.FromSeconds(10));
await task;
}
private static ITorrentItemWrapper MakeTorrent(string name, string savePath)
{
var t = Substitute.For<ITorrentItemWrapper>();
t.Name.Returns(name);
t.SavePath.Returns(savePath);
return t;
}
private IDownloadService SetupDownloadService(DownloadClientConfig clientConfig, List<ITorrentItemWrapper> torrents)
{
var svc = Substitute.For<IDownloadService>();
svc.ClientConfig.Returns(clientConfig);
svc.LoginAsync().Returns(Task.CompletedTask);
svc.GetSeedingDownloads().Returns([]);
svc.GetAllTorrentsLite().Returns(torrents);
_fixture.DownloadServiceFactory.GetDownloadService(clientConfig).Returns(svc);
return svc;
}
[Fact]
public async Task OrphanedFiles_NoEnabledClientConfigs_SkipsScan()
{
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var dbClient = _fixture.DataContext.DownloadClients.First();
SetupDownloadService(dbClient, []);
var sut = CreateSut();
await ExecuteWithTimeAdvance(sut);
_fixture.OrphanedFilesLogger.ReceivedLogContaining(LogLevel.Debug, "No orphaned files settings have been configured");
}
[Fact]
public async Task OrphanedFiles_OrphanedEntry_IsMovedWhenOrphanedDirectorySet()
{
var scanDir = Path.Combine(_tempRoot, "downloads");
var orphanedDir = Path.Combine(_tempRoot, "orphaned");
Directory.CreateDirectory(scanDir);
var orphanedFile = Path.Combine(scanDir, "orphan.mkv");
File.WriteAllText(orphanedFile, "x");
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var dbClient = _fixture.DataContext.DownloadClients.First();
TestDataContextFactory.AddOrphanedFilesConfig(
_fixture.DataContext, dbClient,
scanDirectories: [scanDir],
orphanedDirectory: orphanedDir);
SetupDownloadService(dbClient, []);
var sut = CreateSut();
await ExecuteWithTimeAdvance(sut);
File.Exists(orphanedFile).ShouldBeFalse();
Directory.GetFiles(orphanedDir).ShouldContain(f => Path.GetFileName(f) == "orphan.mkv");
}
[Fact]
public async Task OrphanedFiles_TorrentClaimedEntry_IsNotMoved()
{
var scanDir = Path.Combine(_tempRoot, "downloads");
Directory.CreateDirectory(scanDir);
var claimedDir = Path.Combine(scanDir, "claimed-show");
Directory.CreateDirectory(claimedDir);
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var dbClient = _fixture.DataContext.DownloadClients.First();
TestDataContextFactory.AddOrphanedFilesConfig(
_fixture.DataContext, dbClient,
scanDirectories: [scanDir],
orphanedDirectory: Path.Combine(_tempRoot, "orphaned"));
var torrent = MakeTorrent("claimed-show", scanDir);
SetupDownloadService(dbClient, [torrent]);
var sut = CreateSut();
await ExecuteWithTimeAdvance(sut);
Directory.Exists(claimedDir).ShouldBeTrue();
}
[Fact]
public async Task OrphanedFiles_EntryMatchingExcludePattern_IsNotMoved()
{
var scanDir = Path.Combine(_tempRoot, "downloads");
Directory.CreateDirectory(scanDir);
var skipped = Path.Combine(scanDir, "stuff.nfo");
File.WriteAllText(skipped, "metadata");
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var dbClient = _fixture.DataContext.DownloadClients.First();
TestDataContextFactory.AddOrphanedFilesConfig(
_fixture.DataContext, dbClient,
scanDirectories: [scanDir],
orphanedDirectory: Path.Combine(_tempRoot, "orphaned"),
excludePatterns: ["*.nfo"]);
SetupDownloadService(dbClient, []);
var sut = CreateSut();
await ExecuteWithTimeAdvance(sut);
File.Exists(skipped).ShouldBeTrue();
}
[Fact]
public async Task OrphanedFiles_EntryYoungerThanMinFileAgeHours_IsNotMoved()
{
var scanDir = Path.Combine(_tempRoot, "downloads");
Directory.CreateDirectory(scanDir);
var fresh = Path.Combine(scanDir, "fresh.mkv");
File.WriteAllText(fresh, "x");
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var dbClient = _fixture.DataContext.DownloadClients.First();
TestDataContextFactory.AddOrphanedFilesConfig(
_fixture.DataContext, dbClient,
scanDirectories: [scanDir],
orphanedDirectory: Path.Combine(_tempRoot, "orphaned"),
minFileAgeHours: 1);
SetupDownloadService(dbClient, []);
var sut = CreateSut();
await ExecuteWithTimeAdvance(sut);
File.Exists(fresh).ShouldBeTrue();
}
[Fact]
public async Task OrphanedFiles_NameCollisionInOrphanedDirectory_AppendsTimestampSuffix()
{
var scanDir = Path.Combine(_tempRoot, "downloads");
var orphanedDir = Path.Combine(_tempRoot, "orphaned");
Directory.CreateDirectory(scanDir);
Directory.CreateDirectory(orphanedDir);
File.WriteAllText(Path.Combine(orphanedDir, "dupe.mkv"), "existing");
File.WriteAllText(Path.Combine(scanDir, "dupe.mkv"), "new");
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var dbClient = _fixture.DataContext.DownloadClients.First();
TestDataContextFactory.AddOrphanedFilesConfig(
_fixture.DataContext, dbClient,
scanDirectories: [scanDir],
orphanedDirectory: orphanedDir);
SetupDownloadService(dbClient, []);
var sut = CreateSut();
await ExecuteWithTimeAdvance(sut);
var files = Directory.GetFiles(orphanedDir).Select(Path.GetFileName).ToList();
files.ShouldContain("dupe.mkv");
files.Count(f => f!.StartsWith("dupe.mkv_")).ShouldBe(1);
}
[Fact]
public async Task OrphanedFiles_OrphanedDirectorySelfReference_IsNeverFlagged()
{
var scanDir = Path.Combine(_tempRoot, "downloads");
var orphanedDir = Path.Combine(scanDir, "orphaned");
Directory.CreateDirectory(orphanedDir);
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var dbClient = _fixture.DataContext.DownloadClients.First();
TestDataContextFactory.AddOrphanedFilesConfig(
_fixture.DataContext, dbClient,
scanDirectories: [scanDir],
orphanedDirectory: orphanedDir);
SetupDownloadService(dbClient, []);
var sut = CreateSut();
await ExecuteWithTimeAdvance(sut);
Directory.Exists(orphanedDir).ShouldBeTrue();
}
}

View File

@@ -51,7 +51,9 @@ public class DownloadCleanerTests : IDisposable
_fixture.DownloadServiceFactory,
_fixture.EventPublisher,
_fixture.TimeProvider,
_fixture.HardLinkFileService
_fixture.SeedingRulesService,
_fixture.UnlinkedService,
_fixture.OrphanedFilesService
);
}
@@ -530,7 +532,7 @@ public class DownloadCleanerTests : IDisposable
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.ReceivedLogContaining(LogLevel.Information, "Evaluating");
_fixture.UnlinkedLogger.ReceivedLogContaining(LogLevel.Information, "Evaluating");
}
#endregion
@@ -579,7 +581,7 @@ public class DownloadCleanerTests : IDisposable
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.ReceivedLogContaining(LogLevel.Information, "Evaluating");
_fixture.SeedingRulesLogger.ReceivedLogContaining(LogLevel.Information, "Evaluating");
}
#endregion
@@ -728,7 +730,7 @@ public class DownloadCleanerTests : IDisposable
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.ReceivedLogContaining(LogLevel.Error, "Failed to process unlinked downloads for");
_fixture.UnlinkedLogger.ReceivedLogContaining(LogLevel.Error, "Failed to process unlinked downloads for");
}
[Fact]
@@ -770,7 +772,7 @@ public class DownloadCleanerTests : IDisposable
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.ReceivedLogContaining(LogLevel.Error, "Failed to create category");
_fixture.UnlinkedLogger.ReceivedLogContaining(LogLevel.Error, "Failed to create category");
}
[Fact]
@@ -815,7 +817,7 @@ public class DownloadCleanerTests : IDisposable
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.ReceivedLogContaining(LogLevel.Error, "Failed to process unlinked downloads for");
_fixture.UnlinkedLogger.ReceivedLogContaining(LogLevel.Error, "Failed to process unlinked downloads for");
}
[Fact]
@@ -854,7 +856,7 @@ public class DownloadCleanerTests : IDisposable
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.ReceivedLogContaining(LogLevel.Error, "Failed to clean downloads for");
_fixture.SeedingRulesLogger.ReceivedLogContaining(LogLevel.Error, "Failed to clean downloads for");
}
[Fact]
@@ -899,7 +901,7 @@ public class DownloadCleanerTests : IDisposable
await ExecuteWithTimeAdvance(sut);
// Assert
_logger.ReceivedLogContaining(LogLevel.Error, "Failed to clean downloads for");
_fixture.SeedingRulesLogger.ReceivedLogContaining(LogLevel.Error, "Failed to clean downloads for");
}
[Fact]
@@ -1064,7 +1066,7 @@ public class DownloadCleanerTests : IDisposable
await ExecuteWithTimeAdvance(sut);
// Assert - should log warning about no categories
_logger.ReceivedLogContaining(LogLevel.Warning, "no categories are configured");
_fixture.UnlinkedLogger.ReceivedLogContaining(LogLevel.Warning, "no categories are configured");
}
#endregion

View File

@@ -47,7 +47,9 @@ public class DownloadCleanerIntegrationTests : IDisposable
_fixture.DownloadServiceFactory,
_fixture.EventPublisher,
_fixture.TimeProvider,
_fixture.HardLinkFileService);
_fixture.SeedingRulesService,
_fixture.UnlinkedService,
_fixture.OrphanedFilesService);
}
/// <summary>

View File

@@ -5,11 +5,13 @@ using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Hubs;
@@ -56,6 +58,9 @@ public class IntegrationTestFixture : IDisposable
public IDryRunInterceptor DryRunInterceptor { get; private set; }
public IEventPublisher EventPublisherInterface { get; private set; } = null!;
public IHubContext<AppHub> HubContext { get; private set; }
public ISeedingRulesCleanupService SeedingRulesService { get; private set; } = null!;
public IUnlinkedDownloadsService UnlinkedService { get; private set; } = null!;
public IOrphanedFilesCleanupService OrphanedFilesService { get; private set; } = null!;
// State
public Guid JobRunId { get; private set; }
@@ -91,7 +96,7 @@ public class IntegrationTestFixture : IDisposable
// DryRunInterceptor returns false (not dry run) by default
DryRunInterceptor.IsDryRunEnabled().Returns(false);
DryRunInterceptor.InterceptAsync(default!, default!).ReturnsForAnyArgs(Task.CompletedTask);
DryRunInterceptor.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>()).ReturnsForAnyArgs(Task.CompletedTask);
// Capture messages published to IBus (generic Publish<T> overloads)
MessageBus.Publish(default(QueueItemRemoveRequest<SearchItem>)!, default)
@@ -133,6 +138,19 @@ public class IntegrationTestFixture : IDisposable
EventPublisher,
EventsContext,
DataContext);
SeedingRulesService = new SeedingRulesCleanupService(
Substitute.For<ILogger<SeedingRulesCleanupService>>(),
DataContext);
UnlinkedService = new UnlinkedDownloadsService(
Substitute.For<ILogger<UnlinkedDownloadsService>>(),
DataContext,
HardLinkFileService);
OrphanedFilesService = new OrphanedFilesCleanupService(
Substitute.For<ILogger<OrphanedFilesCleanupService>>(),
DataContext,
TimeProvider,
DryRunInterceptor);
}
/// <summary>

View File

@@ -1,10 +1,12 @@
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
@@ -29,7 +31,14 @@ public class JobHandlerFixture : IDisposable
public IEventPublisher EventPublisher { get; private set; }
public IBlocklistProvider BlocklistProvider { get; private set; }
public IHardLinkFileService HardLinkFileService { get; private set; }
public IDryRunInterceptor DryRunInterceptor { get; private set; }
public FakeTimeProvider TimeProvider { get; private set; }
public ISeedingRulesCleanupService SeedingRulesService { get; private set; }
public IUnlinkedDownloadsService UnlinkedService { get; private set; }
public IOrphanedFilesCleanupService OrphanedFilesService { get; private set; }
public ILogger<SeedingRulesCleanupService> SeedingRulesLogger { get; private set; }
public ILogger<UnlinkedDownloadsService> UnlinkedLogger { get; private set; }
public ILogger<OrphanedFilesCleanupService> OrphanedFilesLogger { get; private set; }
public JobHandlerFixture()
{
@@ -43,7 +52,9 @@ public class JobHandlerFixture : IDisposable
EventPublisher = Substitute.For<IEventPublisher>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
TimeProvider = new FakeTimeProvider();
RecreateCleanupServices();
// Setup default behaviors
SetupDefaultBehaviors();
@@ -52,6 +63,25 @@ public class JobHandlerFixture : IDisposable
ContextProvider.SetJobRunId(Guid.NewGuid());
}
/// <summary>
/// Builds real cleanup services bound to the current DataContext/mocks.
/// Tests can replace any of them with substitutes before constructing
/// the SUT.
/// </summary>
private void RecreateCleanupServices()
{
SeedingRulesLogger = Substitute.For<ILogger<SeedingRulesCleanupService>>();
UnlinkedLogger = Substitute.For<ILogger<UnlinkedDownloadsService>>();
OrphanedFilesLogger = Substitute.For<ILogger<OrphanedFilesCleanupService>>();
SeedingRulesService = new SeedingRulesCleanupService(SeedingRulesLogger, DataContext);
UnlinkedService = new UnlinkedDownloadsService(UnlinkedLogger, DataContext, HardLinkFileService);
OrphanedFilesService = new OrphanedFilesCleanupService(
OrphanedFilesLogger,
DataContext,
TimeProvider,
DryRunInterceptor);
}
private void SetupDefaultBehaviors()
{
// EventPublisher methods return completed task by default
@@ -105,6 +135,7 @@ public class JobHandlerFixture : IDisposable
{
DataContext?.Dispose();
DataContext = TestDataContextFactory.Create(seedData);
RecreateCleanupServices();
return DataContext;
}
@@ -119,8 +150,10 @@ public class JobHandlerFixture : IDisposable
EventPublisher = Substitute.For<IEventPublisher>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
Cache.Clear();
TimeProvider = new FakeTimeProvider();
RecreateCleanupServices();
SetupDefaultBehaviors();

View File

@@ -368,4 +368,32 @@ public static class TestDataContextFactory
return config;
}
public static OrphanedFilesConfig AddOrphanedFilesConfig(
DataContext context,
DownloadClientConfig downloadClient,
bool enabled = true,
List<string>? scanDirectories = null,
string orphanedDirectory = "",
List<string>? excludePatterns = null,
int minFileAgeHours = 0,
int? purgeAfterHours = null)
{
var config = new OrphanedFilesConfig
{
Id = Guid.NewGuid(),
DownloadClientConfigId = downloadClient.Id,
Enabled = enabled,
ScanDirectories = scanDirectories ?? [],
OrphanedDirectory = orphanedDirectory,
ExcludePatterns = excludePatterns ?? [],
MinFileAgeHours = minFileAgeHours,
PurgeAfterHours = purgeAfterHours,
};
context.OrphanedFilesConfigs.Add(config);
context.SaveChanges();
return config;
}
}

View File

@@ -30,14 +30,8 @@ public class NotificationPublisherTests
_configService = Substitute.For<INotificationConfigurationService>();
_providerFactory = Substitute.For<INotificationProviderFactory>();
// Setup dry run interceptor to call through
_dryRunInterceptor.InterceptAsync(default!, default!)
.ReturnsForAnyArgs(ci =>
{
var action = ci.ArgAt<Delegate>(0);
var parameters = ci.ArgAt<object[]>(1);
return action.DynamicInvoke(parameters) as Task ?? Task.CompletedTask;
});
_dryRunInterceptor.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(ci => ci.ArgAt<Func<Task>>(0).Invoke());
_publisher = new NotificationPublisher(
_logger,
@@ -504,8 +498,8 @@ public class NotificationPublisherTests
// Assert
await _dryRunInterceptor.Received(1).InterceptAsync(
Arg.Any<Func<(NotificationEventType, NotificationContext), Task>>(),
Arg.Any<(NotificationEventType, NotificationContext)>());
Arg.Any<Func<Task>>(),
Arg.Any<string?>());
}
#endregion
@@ -517,7 +511,7 @@ public class NotificationPublisherTests
{
// Arrange
// Setup dry run interceptor to throw when called
_dryRunInterceptor.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
_dryRunInterceptor.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ThrowsAsync(new Exception("Interceptor failed"));
SetupContext();
@@ -533,7 +527,7 @@ public class NotificationPublisherTests
public async Task NotifyQueueItemDeleted_WhenExceptionOccurs_LogsError()
{
// Arrange
_dryRunInterceptor.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
_dryRunInterceptor.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ThrowsAsync(new Exception("Error"));
SetupContext();
@@ -549,7 +543,7 @@ public class NotificationPublisherTests
public async Task NotifyDownloadCleaned_WhenExceptionOccurs_LogsError()
{
// Arrange
_dryRunInterceptor.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
_dryRunInterceptor.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ThrowsAsync(new Exception("Error"));
SetupDownloadCleanerContext();
@@ -565,7 +559,7 @@ public class NotificationPublisherTests
public async Task NotifyCategoryChanged_WhenExceptionOccurs_LogsError()
{
// Arrange
_dryRunInterceptor.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
_dryRunInterceptor.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ThrowsAsync(new Exception("Error"));
SetupDownloadCleanerContext();
@@ -625,7 +619,7 @@ public class NotificationPublisherTests
public async Task NotifySearchItemGrabbed_WhenExceptionOccurs_LogsError()
{
// Arrange
_dryRunInterceptor.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
_dryRunInterceptor.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ThrowsAsync(new Exception("Error"));
// Act

View File

@@ -176,7 +176,7 @@ public abstract class ArrClient : IArrClient
using HttpRequestMessage request = new(HttpMethod.Delete, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request));
response?.Dispose();
string logMessage;

View File

@@ -66,7 +66,7 @@ public class LidarrClient : ArrClient, ILidarrClient
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request));
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));

View File

@@ -72,7 +72,7 @@ public class RadarrClient : ArrClient, IRadarrClient
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request));
if (response is null)
{

View File

@@ -72,7 +72,7 @@ public class ReadarrClient : ArrClient, IReadarrClient
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request));
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));

View File

@@ -70,7 +70,7 @@ public class SonarrClient : ArrClient, ISonarrClient
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request));
if (response is not null)
{

View File

@@ -68,7 +68,7 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request));
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, true, logContext));

View File

@@ -73,7 +73,7 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request));
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));

View File

@@ -61,8 +61,8 @@ public sealed class BlacklistSynchronizer : IHandler
string currentHash = ComputeHash(excludedFileNames);
await _dryRunInterceptor.InterceptAsync(SyncBlacklist, currentHash, excludedFileNames);
await _dryRunInterceptor.InterceptAsync(RemoveOldSyncDataAsync, currentHash);
await _dryRunInterceptor.InterceptAsync(() => SyncBlacklist(currentHash, excludedFileNames));
await _dryRunInterceptor.InterceptAsync(() => RemoveOldSyncDataAsync(currentHash));
_logger.LogDebug("Blacklist synchronization completed");
}

View File

@@ -0,0 +1,18 @@
using Cleanuparr.Infrastructure.Features.DownloadClient;
namespace Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
/// <summary>
/// Scans configured directories for files that aren't claimed by any active
/// torrent and moves them to a dedicated orphaned directory. Optionally
/// purges old entries from the orphaned directory.
/// </summary>
public interface IOrphanedFilesCleanupService
{
/// <summary>
/// Processes orphaned files for every enabled per-client configuration.
/// Claims are computed across all download clients to stay safe with
/// cross-seeded torrents.
/// </summary>
Task ProcessAsync(IReadOnlyList<IDownloadService> downloadServices, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,15 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Infrastructure.Features.DownloadClient;
namespace Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
/// <summary>
/// Loads and applies per-client seeding rules to clean completed downloads.
/// </summary>
public interface ISeedingRulesCleanupService
{
/// <summary>
/// Evaluates the seeding rules against the client's downloads and removes those that match.
/// </summary>
Task CleanAsync(IDownloadService downloadService, List<ITorrentItemWrapper> clientDownloads);
}

View File

@@ -0,0 +1,17 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Infrastructure.Features.DownloadClient;
namespace Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
/// <summary>
/// Handles downloads that have lost their hard links by moving them to a
/// dedicated category or tag so they can be cleaned up separately.
/// </summary>
public interface IUnlinkedDownloadsService
{
/// <summary>
/// Re-categorises downloads with no hard links according to the supplied
/// configuration.
/// </summary>
Task ProcessAsync(IDownloadService downloadService, List<ITorrentItemWrapper> clientDownloads);
}

View File

@@ -0,0 +1,311 @@
using System.IO.Enumeration;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
/// <inheritdoc cref="IOrphanedFilesCleanupService" />
public sealed class OrphanedFilesCleanupService : IOrphanedFilesCleanupService
{
private readonly ILogger<OrphanedFilesCleanupService> _logger;
private readonly DataContext _dataContext;
private readonly TimeProvider _timeProvider;
private readonly IDryRunInterceptor _dryRunInterceptor;
public OrphanedFilesCleanupService(
ILogger<OrphanedFilesCleanupService> logger,
DataContext dataContext,
TimeProvider timeProvider,
IDryRunInterceptor dryRunInterceptor)
{
_logger = logger;
_dataContext = dataContext;
_timeProvider = timeProvider;
_dryRunInterceptor = dryRunInterceptor;
}
public async Task ProcessAsync(IReadOnlyList<IDownloadService> downloadServices, CancellationToken cancellationToken)
{
HashSet<Guid> activeClientIds = downloadServices.Select(s => s.ClientConfig.Id).ToHashSet();
if (activeClientIds.Count is 0)
{
_logger.LogWarning("Skipping orphaned-files scan because no download services are available");
return;
}
List<OrphanedFilesConfig> orphanedFilesConfigs = await _dataContext.OrphanedFilesConfigs
.AsNoTracking()
.Include(x => x.DownloadClientConfig)
.Where(x => x.Enabled
&& x.DownloadClientConfig.Enabled
&& activeClientIds.Contains(x.DownloadClientConfigId))
.ToListAsync(cancellationToken);
if (orphanedFilesConfigs.Count is 0)
{
_logger.LogDebug("No orphaned files settings have been configured");
return;
}
// Build set of all content paths claimed by active torrents across ALL download clients
// to avoid false positives from cross-seeded clients.
HashSet<string> claimedPaths = new(StringComparer.OrdinalIgnoreCase);
foreach (IDownloadService downloadService in downloadServices)
{
await AddClaimedPathsAsync(downloadService, claimedPaths);
}
_logger.LogDebug("{count} claimed paths across all clients", claimedPaths.Count);
foreach (OrphanedFilesConfig clientConfig in orphanedFilesConfigs)
{
if (clientConfig.ScanDirectories.Count is 0)
{
_logger.LogWarning("skip | no scan directories configured for client {name}", clientConfig.DownloadClientConfig.Name);
continue;
}
string normalizedOrphanedDir = Path.GetFullPath(clientConfig.OrphanedDirectory)
.TrimEnd(Path.DirectorySeparatorChar);
foreach (string scanDir in clientConfig.ScanDirectories)
{
if (!Directory.Exists(scanDir))
{
_logger.LogWarning("Scan directory does not exist: {dir}", scanDir);
continue;
}
_logger.LogDebug("Scanning {dir}", scanDir);
try
{
ProcessDirectory(scanDir, claimedPaths, clientConfig, normalizedOrphanedDir, cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error scanning {dir} for client {name}", scanDir, clientConfig.DownloadClientConfig.Name);
}
}
PurgeOrphanedDirectory(clientConfig, cancellationToken);
}
}
private async Task AddClaimedPathsAsync(IDownloadService downloadService, HashSet<string> claimedPaths)
{
var downloadClient = downloadService.ClientConfig;
try
{
var torrents = await downloadService.GetAllTorrentsLite();
foreach (var torrent in torrents)
{
if (string.IsNullOrEmpty(torrent.SavePath))
{
continue;
}
string remappedSavePath = PathHelper.NormalizeAndRemap(
torrent.SavePath,
downloadClient.DownloadDirectorySource,
downloadClient.DownloadDirectoryTarget
).TrimEnd(Path.DirectorySeparatorChar);
claimedPaths.Add(remappedSavePath);
if (string.IsNullOrEmpty(torrent.Name))
{
continue;
}
string contentPath = PathHelper.NormalizeAndRemap(
Path.Combine(torrent.SavePath, torrent.Name),
downloadClient.DownloadDirectorySource,
downloadClient.DownloadDirectoryTarget
);
claimedPaths.Add(contentPath.TrimEnd(Path.DirectorySeparatorChar));
}
_logger.LogDebug("Loaded {count} torrent paths from {name}", torrents.Count, downloadClient.Name);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get torrents from client {name}", downloadClient.Name);
}
}
private void ProcessDirectory(
string directory,
HashSet<string> claimedPaths,
OrphanedFilesConfig clientConfig,
string normalizedOrphanedDir,
CancellationToken cancellationToken)
{
foreach (string filePath in Directory.EnumerateFileSystemEntries(directory, "*", new EnumerationOptions { RecurseSubdirectories = false }))
{
cancellationToken.ThrowIfCancellationRequested();
try
{
string normalizedPath = Path.GetFullPath(filePath).TrimEnd(Path.DirectorySeparatorChar);
// Skip reparse points (symlinks/junctions)
if ((File.GetAttributes(normalizedPath) & FileAttributes.ReparsePoint) != 0)
{
_logger.LogWarning("skip | reparse point | {path}", normalizedPath);
continue;
}
if (normalizedPath.Equals(normalizedOrphanedDir, StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug("skip | orphaned directory itself | {path}", normalizedPath);
continue;
}
if (claimedPaths.Contains(normalizedPath))
{
_logger.LogDebug("skip | claimed by torrent | {path}", normalizedPath);
continue;
}
string entryName = Path.GetFileName(normalizedPath);
if (clientConfig.ExcludePatterns.Any(pattern => FileSystemName.MatchesSimpleExpression(pattern, entryName, ignoreCase: true)))
{
_logger.LogDebug("skip | excluded by pattern | {path}", normalizedPath);
continue;
}
if (clientConfig.MinFileAgeHours > 0)
{
DateTime lastWrite = File.GetLastWriteTimeUtc(normalizedPath);
DateTime created = File.GetCreationTimeUtc(normalizedPath);
DateTime mostRecent = lastWrite > created ? lastWrite : created;
double ageHours = (_timeProvider.GetUtcNow().UtcDateTime - mostRecent).TotalHours;
if (ageHours < clientConfig.MinFileAgeHours)
{
_logger.LogDebug(
"skip | too recent ({age:F1}h < {min}h) | {path}",
ageHours, clientConfig.MinFileAgeHours, normalizedPath);
continue;
}
}
_logger.LogInformation("orphaned entry found | {path}", normalizedPath);
string capturedEntry = normalizedPath;
string capturedOrphanedDir = normalizedOrphanedDir;
_dryRunInterceptor.Intercept(() => MoveToOrphanedDirectory(capturedEntry, capturedOrphanedDir));
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to handle orphaned entry: {path}", filePath);
}
}
}
private void MoveToOrphanedDirectory(string path, string orphanedDirectory)
{
string entryName = Path.GetFileName(path);
string destination = Path.Combine(orphanedDirectory, entryName);
if (Path.Exists(destination))
{
const int maxAttempts = 100;
string timestamp = _timeProvider.GetUtcNow().UtcDateTime.ToString("yyyyMMddHHmmss");
destination = Path.Combine(orphanedDirectory, $"{entryName}_{timestamp}");
int counter = 1;
while (Path.Exists(destination))
{
if (counter > maxAttempts)
{
throw new InvalidOperationException($"Could not find a free destination name for orphaned entry after {maxAttempts} attempts: {path}");
}
destination = Path.Combine(orphanedDirectory, $"{entryName}_{timestamp}_{counter}");
counter++;
}
}
Directory.CreateDirectory(orphanedDirectory);
DateTime now = _timeProvider.GetUtcNow().UtcDateTime;
if (Directory.Exists(path))
{
Directory.Move(path, destination);
Directory.SetLastWriteTimeUtc(destination, now);
}
else
{
File.Move(path, destination);
File.SetLastWriteTimeUtc(destination, now);
}
_logger.LogInformation("orphaned entry moved | {source} -> {dest}", path, destination);
}
private void PurgeOrphanedDirectory(OrphanedFilesConfig clientConfig, CancellationToken cancellationToken)
{
if (!clientConfig.PurgeAfterHours.HasValue)
{
return;
}
if (!Directory.Exists(clientConfig.OrphanedDirectory))
{
return;
}
DateTime cutoff = _timeProvider.GetUtcNow().UtcDateTime.AddHours(-clientConfig.PurgeAfterHours.Value);
foreach (string filePath in Directory.EnumerateFileSystemEntries(clientConfig.OrphanedDirectory))
{
cancellationToken.ThrowIfCancellationRequested();
DateTime lastWrite = File.GetLastWriteTimeUtc(filePath);
if (lastWrite > cutoff)
{
continue;
}
try
{
int hours = clientConfig.PurgeAfterHours.Value;
if (Directory.Exists(filePath))
{
Directory.Delete(filePath, recursive: true);
}
else
{
File.Delete(filePath);
}
_logger.LogInformation("Purged old orphaned entry ({hours}h+) | {path}", hours, filePath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to purge orphaned entry: {path}", filePath);
}
}
}
}

View File

@@ -0,0 +1,69 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
/// <inheritdoc cref="ISeedingRulesCleanupService" />
public sealed class SeedingRulesCleanupService : ISeedingRulesCleanupService
{
private readonly ILogger<SeedingRulesCleanupService> _logger;
private readonly DataContext _dataContext;
public SeedingRulesCleanupService(ILogger<SeedingRulesCleanupService> logger, DataContext dataContext)
{
_logger = logger;
_dataContext = dataContext;
}
public async Task CleanAsync(IDownloadService downloadService, List<ITorrentItemWrapper> clientDownloads)
{
try
{
DownloadClientConfig config = downloadService.ClientConfig;
List<ISeedingRule> seedingRules = config.TypeName switch
{
DownloadClientTypeName.qBittorrent => (await _dataContext.QBitSeedingRules
.Where(r => r.DownloadClientConfigId == config.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Deluge => (await _dataContext.DelugeSeedingRules
.Where(r => r.DownloadClientConfigId == config.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Transmission => (await _dataContext.TransmissionSeedingRules
.Where(r => r.DownloadClientConfigId == config.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.uTorrent => (await _dataContext.UTorrentSeedingRules
.Where(r => r.DownloadClientConfigId == config.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.rTorrent => (await _dataContext.RTorrentSeedingRules
.Where(r => r.DownloadClientConfigId == config.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
_ => []
};
if (seedingRules.Count is 0)
{
_logger.LogDebug("No seeding rules found for {clientName}", downloadService.ClientConfig.Name);
return;
}
List<ITorrentItemWrapper>? downloadsToClean = downloadService
.FilterDownloadsToBeCleanedAsync(clientDownloads, seedingRules);
if (downloadsToClean?.Count is null or 0)
{
return;
}
_logger.LogInformation("Evaluating {count} downloads for cleanup", downloadsToClean.Count);
await downloadService.CleanDownloadsAsync(downloadsToClean, seedingRules);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to clean downloads for {clientName}", downloadService.ClientConfig.Name);
}
_logger.LogInformation("Finished cleanup evaluation");
}
}

View File

@@ -0,0 +1,80 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
/// <inheritdoc cref="IUnlinkedDownloadsService" />
public sealed class UnlinkedDownloadsService : IUnlinkedDownloadsService
{
private readonly ILogger<UnlinkedDownloadsService> _logger;
private readonly DataContext _dataContext;
private readonly IHardLinkFileService _hardLinkFileService;
public UnlinkedDownloadsService(
ILogger<UnlinkedDownloadsService> logger,
DataContext dataContext,
IHardLinkFileService hardLinkFileService)
{
_logger = logger;
_dataContext = dataContext;
_hardLinkFileService = hardLinkFileService;
}
public async Task ProcessAsync(IDownloadService downloadService, List<ITorrentItemWrapper> clientDownloads)
{
UnlinkedConfig? unlinkedConfig = await _dataContext.UnlinkedConfigs
.AsNoTracking()
.FirstOrDefaultAsync(u => u.DownloadClientConfigId == downloadService.ClientConfig.Id);
if (unlinkedConfig is not { Enabled: true })
{
return;
}
if (unlinkedConfig.Categories.Count is 0)
{
_logger.LogWarning("Unlinked config is enabled but no categories are configured for {name}", downloadService.ClientConfig.Name);
return;
}
try
{
if (unlinkedConfig.IgnoredRootDirs.Count > 0)
{
_hardLinkFileService.PopulateFileCounts(unlinkedConfig.IgnoredRootDirs);
}
List<ITorrentItemWrapper>? downloadsToChangeCategory = downloadService
.FilterDownloadsToChangeCategoryAsync(clientDownloads, unlinkedConfig);
if (downloadsToChangeCategory?.Count is null or 0)
{
return;
}
_logger.LogInformation("Evaluating {count} downloads for hardlinks", downloadsToChangeCategory.Count);
try
{
await downloadService.CreateCategoryAsync(unlinkedConfig.TargetCategory);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create category {category}", unlinkedConfig.TargetCategory);
}
await downloadService.ChangeCategoryForNoHardLinksAsync(downloadsToChangeCategory, unlinkedConfig);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process unlinked downloads for {clientName}", downloadService.ClientConfig.Name);
}
_logger.LogInformation("Finished hardlinks evaluation");
}
}

View File

@@ -125,7 +125,7 @@ public partial class DelugeService
_logger.LogDebug("Marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
await _dryRunInterceptor.InterceptAsync(ChangeFilesPriority, hash, sortedPriorities);
await _dryRunInterceptor.InterceptAsync(() => ChangeFilesPriority(hash, sortedPriorities));
return result;
}

View File

@@ -26,6 +26,21 @@ public partial class DelugeService
.ToList();
}
/// <inheritdoc/>
public override async Task<List<ITorrentItemWrapper>> GetAllTorrentsLite()
{
var downloads = await _client.GetStatusForAllTorrents();
if (downloads is null)
{
return [];
}
return downloads
.Where(x => !string.IsNullOrEmpty(x.Hash))
.Select(ITorrentItemWrapper (x) => new DelugeItemWrapper(x))
.ToList();
}
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
@@ -56,7 +71,7 @@ public partial class DelugeService
_logger.LogDebug("Creating category {name}", name);
await _dryRunInterceptor.InterceptAsync(CreateLabel, name);
await _dryRunInterceptor.InterceptAsync(() => CreateLabel(name));
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig)
@@ -93,9 +108,10 @@ public partial class DelugeService
ProcessFiles(contents?.Contents, (_, file) =>
{
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.Info.DownloadLocation, file.Path).Split(['\\', '/']));
filePath = PathHelper.RemapPath(filePath, unlinkedConfig.DownloadDirectorySource, unlinkedConfig.DownloadDirectoryTarget);
string filePath = PathHelper.NormalizeAndRemap(
Path.Combine(torrent.Info.DownloadLocation, file.Path),
_downloadClientConfig.DownloadDirectorySource,
_downloadClientConfig.DownloadDirectoryTarget);
if (file.Priority <= 0)
{
@@ -130,7 +146,7 @@ public partial class DelugeService
continue;
}
await _dryRunInterceptor.InterceptAsync(ChangeLabel, torrent.Hash, unlinkedConfig.TargetCategory);
await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, unlinkedConfig.TargetCategory));
_logger.LogInformation("category changed for {name}", torrent.Name);

View File

@@ -74,6 +74,9 @@ public abstract class DownloadService : IDownloadService
/// <inheritdoc/>
public abstract Task<List<ITorrentItemWrapper>> GetSeedingDownloads();
/// <inheritdoc/>
public abstract Task<List<ITorrentItemWrapper>> GetAllTorrentsLite();
/// <inheritdoc/>
public abstract List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules);

View File

@@ -30,6 +30,13 @@ public interface IDownloadService : IDisposable
/// <returns>A list of downloads that are seeding.</returns>
Task<List<ITorrentItemWrapper>> GetSeedingDownloads();
/// <summary>
/// Fetches all torrents regardless of their state, without per-torrent tracker or properties calls.
/// Used by the orphaned files cleanup to identify which paths are claimed by active torrents.
/// </summary>
/// <returns>A list of all torrents.</returns>
Task<List<ITorrentItemWrapper>> GetAllTorrentsLite();
/// <summary>
/// Filters downloads that should be cleaned.
/// </summary>

View File

@@ -120,7 +120,7 @@ public partial class QBitService
foreach (int fileIndex in unwantedFiles)
{
await _dryRunInterceptor.InterceptAsync(MarkFileAsSkipped, hash, fileIndex);
await _dryRunInterceptor.InterceptAsync(() => MarkFileAsSkipped(hash, fileIndex));
}
return result;

View File

@@ -33,6 +33,21 @@ public partial class QBitService
return result;
}
/// <inheritdoc/>
public override async Task<List<ITorrentItemWrapper>> GetAllTorrentsLite()
{
var torrentList = await _client.GetTorrentListAsync(new TorrentListQuery());
if (torrentList is null)
{
return [];
}
return torrentList
.Where(x => !string.IsNullOrEmpty(x.Hash))
.Select(ITorrentItemWrapper (t) => new QBitItemWrapper(t, [], false))
.ToList();
}
/// <inheritdoc/>
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
@@ -76,7 +91,7 @@ public partial class QBitService
_logger.LogDebug("Creating category {name}", name);
await _dryRunInterceptor.InterceptAsync(CreateCategory, name);
await _dryRunInterceptor.InterceptAsync(() => CreateCategory(name));
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig)
@@ -116,9 +131,10 @@ public partial class QBitService
break;
}
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.Info.SavePath, file.Name).Split(['\\', '/']));
filePath = PathHelper.RemapPath(filePath, unlinkedConfig.DownloadDirectorySource, unlinkedConfig.DownloadDirectoryTarget);
string filePath = PathHelper.NormalizeAndRemap(
Path.Combine(torrent.Info.SavePath, file.Name),
_downloadClientConfig.DownloadDirectorySource,
_downloadClientConfig.DownloadDirectoryTarget);
if (file.Priority is TorrentContentPriority.Skip)
{
@@ -153,7 +169,7 @@ public partial class QBitService
continue;
}
await _dryRunInterceptor.InterceptAsync(ChangeCategory, torrent.Hash, unlinkedConfig.TargetCategory, unlinkedConfig.UseTag);
await _dryRunInterceptor.InterceptAsync(() => ChangeCategory(torrent.Hash, unlinkedConfig.TargetCategory, unlinkedConfig.UseTag));
await _eventPublisher.PublishCategoryChanged(torrent.Category, unlinkedConfig.TargetCategory, unlinkedConfig.UseTag);

View File

@@ -125,7 +125,7 @@ public partial class RTorrentService
foreach (var (index, priority) in priorityUpdates)
{
await _dryRunInterceptor.InterceptAsync(SetFilePriority, hash, index, priority);
await _dryRunInterceptor.InterceptAsync(() => SetFilePriority(hash, index, priority));
}
return result;

View File

@@ -21,6 +21,17 @@ public partial class RTorrentService
.ToList();
}
/// <inheritdoc/>
public override async Task<List<ITorrentItemWrapper>> GetAllTorrentsLite()
{
var downloads = await _client.GetAllTorrentsAsync();
return downloads
.Where(x => !string.IsNullOrEmpty(x.Hash))
.Select(ITorrentItemWrapper (x) => new RTorrentItemWrapper(x))
.ToList();
}
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
@@ -90,10 +101,10 @@ public partial class RTorrentService
foreach (var file in files)
{
string filePath = string.Join(Path.DirectorySeparatorChar,
Path.Combine(torrent.Info.Directory ?? torrent.Info.BasePath ?? "", file.Path).Split(['\\', '/']));
filePath = PathHelper.RemapPath(filePath, unlinkedConfig.DownloadDirectorySource, unlinkedConfig.DownloadDirectoryTarget);
string filePath = PathHelper.NormalizeAndRemap(
Path.Combine(torrent.Info.Directory ?? torrent.Info.BasePath ?? "", file.Path),
_downloadClientConfig.DownloadDirectorySource,
_downloadClientConfig.DownloadDirectoryTarget);
if (file.Priority <= 0)
{
@@ -129,7 +140,7 @@ public partial class RTorrentService
continue;
}
await _dryRunInterceptor.InterceptAsync(ChangeLabel, torrent.Hash, unlinkedConfig.TargetCategory);
await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, unlinkedConfig.TargetCategory));
_logger.LogInformation("category changed for {name}", torrent.Name);

View File

@@ -101,7 +101,7 @@ public partial class TransmissionService
_logger.LogDebug("Marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
await _dryRunInterceptor.InterceptAsync(SetUnwantedFiles, download.Id, unwantedFiles.ToArray());
await _dryRunInterceptor.InterceptAsync(() => SetUnwantedFiles(download.Id, unwantedFiles.ToArray()));
return result;
}

View File

@@ -20,6 +20,16 @@ public partial class TransmissionService
.ToList() ?? [];
}
/// <inheritdoc/>
public override async Task<List<ITorrentItemWrapper>> GetAllTorrentsLite()
{
var result = await _client.TorrentGetAsync(Fields);
return result?.Torrents
?.Where(x => !string.IsNullOrEmpty(x.HashString))
.Select(ITorrentItemWrapper (x) => new TransmissionItemWrapper(x))
.ToList() ?? [];
}
/// <inheritdoc/>
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules)
{
@@ -85,9 +95,10 @@ public partial class TransmissionService
continue;
}
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.Info.DownloadDir, file.Name).Split(['\\', '/']));
filePath = PathHelper.RemapPath(filePath, unlinkedConfig.DownloadDirectorySource, unlinkedConfig.DownloadDirectoryTarget);
string filePath = PathHelper.NormalizeAndRemap(
Path.Combine(torrent.Info.DownloadDir, file.Name),
_downloadClientConfig.DownloadDirectorySource,
_downloadClientConfig.DownloadDirectoryTarget);
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, unlinkedConfig.IgnoredRootDirs.Count > 0);
@@ -119,7 +130,7 @@ public partial class TransmissionService
string currentCategory = torrent.Category ?? string.Empty;
string newLocation = torrent.Info.GetNewLocationByAppend(unlinkedConfig.TargetCategory);
await _dryRunInterceptor.InterceptAsync(ChangeDownloadLocation, torrent.Info.Id, newLocation);
await _dryRunInterceptor.InterceptAsync(() => ChangeDownloadLocation(torrent.Info.Id, newLocation));
_logger.LogInformation("category changed for {name}", torrent.Name);

View File

@@ -243,22 +243,24 @@ public sealed class UTorrentClient
// Get valid token and cookie from cache-aware authenticator
var token = await _authenticator.GetValidTokenAsync();
var guidCookie = await _authenticator.GetValidGuidCookieAsync();
request.Token = token;
return await _httpService.SendRawRequestAsync(request, guidCookie);
}
catch (UTorrentAuthenticationException)
catch (Exception firstAttemptError) when (
firstAttemptError is UTorrentAuthenticationException ||
(firstAttemptError is UTorrentException && firstAttemptError.Message.Contains("BadRequest", StringComparison.OrdinalIgnoreCase)))
{
// On authentication failure, invalidate cache and retry once
// µTorrent returns BadRequest when the token or GUID cookie no longer matches the running server
try
{
await _authenticator.InvalidateSessionAsync();
var token = await _authenticator.GetValidTokenAsync();
var guidCookie = await _authenticator.GetValidGuidCookieAsync();
request.Token = token;
return await _httpService.SendRawRequestAsync(request, guidCookie);
}
catch (Exception ex)

View File

@@ -95,7 +95,7 @@ public partial class UTorrentService
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
await _dryRunInterceptor.InterceptAsync(ChangeFilesPriority, hash, fileIndexes);
await _dryRunInterceptor.InterceptAsync(() => ChangeFilesPriority(hash, fileIndexes));
return result;
}

View File

@@ -25,6 +25,17 @@ public partial class UTorrentService
return result;
}
/// <inheritdoc/>
public override async Task<List<ITorrentItemWrapper>> GetAllTorrentsLite()
{
var torrents = await _client.GetTorrentsAsync();
return torrents
.Where(x => !string.IsNullOrEmpty(x.Hash))
.Select(ITorrentItemWrapper (x) => new UTorrentItemWrapper(x, new UTorrentProperties()))
.ToList();
}
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
@@ -73,9 +84,10 @@ public partial class UTorrentService
foreach (var file in files ?? [])
{
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.Info.SavePath, file.Name).Split(['\\', '/']));
filePath = PathHelper.RemapPath(filePath, unlinkedConfig.DownloadDirectorySource, unlinkedConfig.DownloadDirectoryTarget);
string filePath = PathHelper.NormalizeAndRemap(
Path.Combine(torrent.Info.SavePath, file.Name),
_downloadClientConfig.DownloadDirectorySource,
_downloadClientConfig.DownloadDirectoryTarget);
if (file.Priority <= 0)
{
@@ -111,7 +123,7 @@ public partial class UTorrentService
continue;
}
await _dryRunInterceptor.InterceptAsync(ChangeLabel, torrent.Hash, unlinkedConfig.TargetCategory);
await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, unlinkedConfig.TargetCategory));
await _eventPublisher.PublishCategoryChanged(torrent.Category, unlinkedConfig.TargetCategory);

View File

@@ -5,14 +5,13 @@ using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using LogContext = Serilog.Context.LogContext;
@@ -21,9 +20,11 @@ namespace Cleanuparr.Infrastructure.Features.Jobs;
public sealed class DownloadCleaner : GenericHandler
{
private readonly HashSet<string> _downloadsProcessedByArrs = [];
private readonly HashSet<string> _downloadsProcessedByArrs = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
private readonly IHardLinkFileService _hardLinkFileService;
private readonly ISeedingRulesCleanupService _seedingRulesService;
private readonly IUnlinkedDownloadsService _unlinkedService;
private readonly IOrphanedFilesCleanupService _orphanedFilesService;
public DownloadCleaner(
ILogger<DownloadCleaner> logger,
@@ -35,19 +36,23 @@ public sealed class DownloadCleaner : GenericHandler
IDownloadServiceFactory downloadServiceFactory,
IEventPublisher eventPublisher,
TimeProvider timeProvider,
IHardLinkFileService hardLinkFileService
ISeedingRulesCleanupService seedingRulesService,
IUnlinkedDownloadsService unlinkedService,
IOrphanedFilesCleanupService orphanedFilesService
) : base(
logger, dataContext, cache, messageBus,
arrClientFactory, arrArrQueueIterator, downloadServiceFactory, eventPublisher
)
{
_timeProvider = timeProvider;
_hardLinkFileService = hardLinkFileService;
_seedingRulesService = seedingRulesService;
_unlinkedService = unlinkedService;
_orphanedFilesService = orphanedFilesService;
}
protected override async Task ExecuteInternalAsync(CancellationToken cancellationToken = default)
{
var downloadServices = await GetInitializedDownloadServicesAsync();
IReadOnlyList<IDownloadService> downloadServices = await GetInitializedDownloadServicesAsync();
if (downloadServices.Count is 0)
{
@@ -55,21 +60,45 @@ public sealed class DownloadCleaner : GenericHandler
return;
}
var config = ContextProvider.Get<DownloadCleanerConfig>();
try
{
await RunCleanupAsync(downloadServices, cancellationToken);
}
finally
{
foreach (IDownloadService downloadService in downloadServices)
{
try
{
downloadService.Dispose();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to dispose download service {name}", downloadService.ClientConfig.Name);
}
}
}
}
private async Task RunCleanupAsync(IReadOnlyList<IDownloadService> downloadServices, CancellationToken cancellationToken)
{
DownloadCleanerConfig config = ContextProvider.Get<DownloadCleanerConfig>();
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(config.IgnoredDownloads);
var downloadServiceToDownloadsMap = new Dictionary<IDownloadService, List<ITorrentItemWrapper>>();
Dictionary<IDownloadService, List<ITorrentItemWrapper>> downloadServiceToDownloadsMap = new();
List<IDownloadService> loggedInServices = new();
foreach (var downloadService in downloadServices)
foreach (IDownloadService downloadService in downloadServices)
{
using var dcType = LogContext.PushProperty(LogProperties.DownloadClientType, downloadService.ClientConfig.Type.ToString());
using var dcName = LogContext.PushProperty(LogProperties.DownloadClientName, downloadService.ClientConfig.Name);
using IDisposable _ = LogContext.PushProperty(LogProperties.DownloadClientType, downloadService.ClientConfig.Type.ToString());
using IDisposable _2 = LogContext.PushProperty(LogProperties.DownloadClientName, downloadService.ClientConfig.Name);
try
{
await downloadService.LoginAsync();
loggedInServices.Add(downloadService);
List<ITorrentItemWrapper> clientDownloads = await downloadService.GetSeedingDownloads();
if (clientDownloads.Count > 0)
@@ -83,208 +112,88 @@ public sealed class DownloadCleaner : GenericHandler
}
}
if (downloadServiceToDownloadsMap.Count is 0)
{
_logger.LogInformation("No seeding downloads found");
return;
}
int totalDownloads = downloadServiceToDownloadsMap.Values.Sum(x => x.Count);
_logger.LogTrace("Found {count} seeding downloads across {clientCount} clients", totalDownloads, downloadServiceToDownloadsMap.Count);
// wait for the downloads to appear in the arr queue
await Task.Delay(TimeSpan.FromSeconds(10), _timeProvider, cancellationToken);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr)), true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr)), true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr)), true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr)), true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr)), true);
foreach (var pair in downloadServiceToDownloadsMap)
if (downloadServiceToDownloadsMap.Count > 0)
{
List<ITorrentItemWrapper> filteredDownloads = [];
// wait for the downloads to appear in the arr queue
await Task.Delay(TimeSpan.FromSeconds(10), _timeProvider, cancellationToken);
foreach (ITorrentItemWrapper download in pair.Value)
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr)), true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr)), true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr)), true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr)), true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr)), true);
foreach (KeyValuePair<IDownloadService, List<ITorrentItemWrapper>> pair in downloadServiceToDownloadsMap)
{
if (download.IsIgnored(ignoredDownloads))
List<ITorrentItemWrapper> filteredDownloads = [];
foreach (ITorrentItemWrapper download in pair.Value)
{
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
continue;
if (download.IsIgnored(ignoredDownloads))
{
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
continue;
}
if (_downloadsProcessedByArrs.Contains(download.Hash))
{
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
continue;
}
filteredDownloads.Add(download);
}
if (_downloadsProcessedByArrs.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
{
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
continue;
}
filteredDownloads.Add(download);
downloadServiceToDownloadsMap[pair.Key] = filteredDownloads;
}
downloadServiceToDownloadsMap[pair.Key] = filteredDownloads;
}
// Process each client with its own per-client config
foreach (var (downloadService, clientDownloads) in downloadServiceToDownloadsMap)
{
using var dcType = LogContext.PushProperty(LogProperties.DownloadClientType, downloadService.ClientConfig.Type.ToString());
using var dcName = LogContext.PushProperty(LogProperties.DownloadClientName, downloadService.ClientConfig.Name);
var seedingRules = await LoadSeedingRulesForClient(downloadService.ClientConfig);
var unlinkedConfig = await LoadUnlinkedConfigForClient(downloadService.ClientConfig.Id);
if (unlinkedConfig is { Enabled: true })
foreach ((IDownloadService downloadService, List<ITorrentItemWrapper> clientDownloads) in downloadServiceToDownloadsMap)
{
if (unlinkedConfig.Categories.Count > 0)
{
await ChangeUnlinkedCategoriesForClientAsync(downloadService, clientDownloads, unlinkedConfig);
}
else
{
_logger.LogWarning("Unlinked config is enabled but no categories are configured for {name}, skipping", downloadService.ClientConfig.Name);
}
}
using IDisposable _ = LogContext.PushProperty(LogProperties.DownloadClientType, downloadService.ClientConfig.Type.ToString());
using IDisposable _2 = LogContext.PushProperty(LogProperties.DownloadClientName, downloadService.ClientConfig.Name);
if (seedingRules.Count > 0)
{
await CleanDownloadsForClientAsync(downloadService, clientDownloads, seedingRules);
await _unlinkedService.ProcessAsync(downloadService, clientDownloads);
await _seedingRulesService.CleanAsync(downloadService, clientDownloads);
}
}
foreach (var downloadService in downloadServices)
else
{
downloadService.Dispose();
_logger.LogInformation("No seeding downloads found, skipping seeding-rule and unlinked-category processing");
}
try
{
await _orphanedFilesService.ProcessAsync(loggedInServices, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process orphaned files");
}
}
protected override async Task ProcessInstanceAsync(ArrInstance instance)
{
using var _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString());
using var _2 = LogContext.PushProperty(LogProperties.InstanceName, instance.Name);
using IDisposable _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString());
using IDisposable _2 = LogContext.PushProperty(LogProperties.InstanceName, instance.Name);
IArrClient arrClient = _arrClientFactory.GetClient(instance.ArrConfig.Type, instance.Version);
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
await _arrArrQueueIterator.Iterate(arrClient, instance, items =>
{
var groups = items
List<IGrouping<string, QueueRecord>> groups = items
.Where(x => !string.IsNullOrEmpty(x.DownloadId))
.GroupBy(x => x.DownloadId)
.ToList();
foreach (QueueRecord record in groups.Select(group => group.First()))
{
_downloadsProcessedByArrs.Add(record.DownloadId.ToLowerInvariant());
_downloadsProcessedByArrs.Add(record.DownloadId);
}
return Task.CompletedTask;
});
}
private async Task ChangeUnlinkedCategoriesForClientAsync(
IDownloadService downloadService,
List<ITorrentItemWrapper> clientDownloads,
UnlinkedConfig unlinkedConfig)
{
if (unlinkedConfig.IgnoredRootDirs.Count > 0)
{
_hardLinkFileService.PopulateFileCounts(unlinkedConfig.IgnoredRootDirs);
}
try
{
var downloadsToChangeCategory = downloadService
.FilterDownloadsToChangeCategoryAsync(clientDownloads, unlinkedConfig);
if (downloadsToChangeCategory?.Count is null or 0)
{
return;
}
_logger.LogInformation("Evaluating {count} downloads for hardlinks", downloadsToChangeCategory.Count);
try
{
await downloadService.CreateCategoryAsync(unlinkedConfig.TargetCategory);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create category {category}", unlinkedConfig.TargetCategory);
}
await downloadService.ChangeCategoryForNoHardLinksAsync(downloadsToChangeCategory, unlinkedConfig);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process unlinked downloads for {clientName}", downloadService.ClientConfig.Name);
}
_logger.LogInformation("Finished hardlinks evaluation");
}
private async Task CleanDownloadsForClientAsync(
IDownloadService downloadService,
List<ITorrentItemWrapper> clientDownloads,
List<ISeedingRule> seedingRules)
{
try
{
var downloadsToClean = downloadService
.FilterDownloadsToBeCleanedAsync(clientDownloads, seedingRules);
if (downloadsToClean?.Count is null or 0)
{
return;
}
_logger.LogInformation("Evaluating {count} downloads for cleanup", downloadsToClean.Count);
await downloadService.CleanDownloadsAsync(downloadsToClean, seedingRules);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to clean downloads for {clientName}", downloadService.ClientConfig.Name);
}
_logger.LogInformation("Finished cleanup evaluation");
}
private async Task<List<ISeedingRule>> LoadSeedingRulesForClient(Persistence.Models.Configuration.DownloadClientConfig clientConfig)
{
await DataContext.Lock.WaitAsync();
try
{
return clientConfig.TypeName switch
{
DownloadClientTypeName.qBittorrent => (await _dataContext.QBitSeedingRules
.Where(r => r.DownloadClientConfigId == clientConfig.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Deluge => (await _dataContext.DelugeSeedingRules
.Where(r => r.DownloadClientConfigId == clientConfig.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.Transmission => (await _dataContext.TransmissionSeedingRules
.Where(r => r.DownloadClientConfigId == clientConfig.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.uTorrent => (await _dataContext.UTorrentSeedingRules
.Where(r => r.DownloadClientConfigId == clientConfig.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
DownloadClientTypeName.rTorrent => (await _dataContext.RTorrentSeedingRules
.Where(r => r.DownloadClientConfigId == clientConfig.Id).AsNoTracking().ToListAsync()).Cast<ISeedingRule>().ToList(),
_ => []
};
}
finally
{
DataContext.Lock.Release();
}
}
private async Task<UnlinkedConfig?> LoadUnlinkedConfigForClient(Guid clientId)
{
await DataContext.Lock.WaitAsync();
try
{
return await _dataContext.UnlinkedConfigs
.AsNoTracking()
.FirstOrDefaultAsync(u => u.DownloadClientConfigId == clientId);
}
finally
{
DataContext.Lock.Release();
}
}
}

View File

@@ -110,7 +110,7 @@ public class NotificationPublisher : INotificationPublisher
private async Task SendNotificationAsync(NotificationEventType eventType, NotificationContext context)
{
await _dryRunInterceptor.InterceptAsync(SendNotificationInternalAsync, (eventType, context));
await _dryRunInterceptor.InterceptAsync(() => SendNotificationInternalAsync((eventType, context)));
}
private async Task SendNotificationInternalAsync((NotificationEventType eventType, NotificationContext context) parameters)

View File

@@ -58,6 +58,7 @@ public class DynamicHttpClientProvider : IDynamicHttpClientProvider
var clientType = downloadClientConfig.TypeName switch
{
DownloadClientTypeName.Deluge => HttpClientType.Deluge,
DownloadClientTypeName.uTorrent => HttpClientType.UTorrent,
_ => HttpClientType.WithRetry
};

View File

@@ -74,6 +74,17 @@ public class DynamicHttpClientConfiguration : IConfigureNamedOptions<HttpClientF
};
break;
case HttpClientType.UTorrent:
// uTorrent's WebUI requires the GUID cookie+token pair set manually by UTorrentHttpService
// UseCookies=false prevents .NET's CookieContainer from injecting a competing Cookie header.
builder.PrimaryHandler = new HttpClientHandler
{
UseCookies = false,
ServerCertificateCustomValidationCallback = (sender, certificate, chain, policy) =>
certValidationService.ShouldByPassValidationError(config.CertificateValidationType, sender, certificate, chain, policy),
};
break;
case HttpClientType.Default:
default:
// Use default handler with certificate validation

View File

@@ -36,5 +36,6 @@ public enum HttpClientType
{
Default,
WithRetry,
Deluge
Deluge,
UTorrent,
}

View File

@@ -1,90 +1,105 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.General;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Interceptors;
public class DryRunInterceptor : IDryRunInterceptor
public partial class DryRunInterceptor : IDryRunInterceptor
{
private readonly ILogger<DryRunInterceptor> _logger;
private readonly DataContext _dataContext;
[GeneratedRegex(@"(\w+)\s*\(")]
private static partial Regex MethodNameRegex();
public DryRunInterceptor(ILogger<DryRunInterceptor> logger, DataContext dataContext)
{
_logger = logger;
_dataContext = dataContext;
}
public void Intercept(Action action)
public void Intercept(
Action action,
[CallerArgumentExpression(nameof(action))] string? expression = null)
{
MethodInfo methodInfo = action.Method;
var config = _dataContext.GeneralConfigs
.AsNoTracking()
.First();
if (config.DryRun)
if (IsDryRun(expression))
{
_logger.LogInformation("[DRY RUN] skipping method: {name}", methodInfo.Name);
return;
}
action();
}
public async Task InterceptAsync(Delegate action, params object[] parameters)
public async Task InterceptAsync(
Func<Task> action,
[CallerArgumentExpression(nameof(action))] string? expression = null)
{
MethodInfo methodInfo = action.Method;
var config = await _dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync();
if (config.DryRun)
if (await IsDryRunAsync(expression))
{
_logger.LogInformation("[DRY RUN] skipping method: {name}", methodInfo.Name);
return;
}
object? result = action.DynamicInvoke(parameters);
if (result is Task task)
{
await task;
}
await action();
}
public async Task<T?> InterceptAsync<T>(Delegate action, params object[] parameters)
public async Task<T?> InterceptAsync<T>(
Func<Task<T>> action,
[CallerArgumentExpression(nameof(action))] string? expression = null)
{
MethodInfo methodInfo = action.Method;
var config = await _dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync();
if (config.DryRun)
if (await IsDryRunAsync(expression))
{
_logger.LogInformation("[DRY RUN] skipping method: {name}", methodInfo.Name);
return default;
}
object? result = action.DynamicInvoke(parameters);
if (result is Task<T?> task)
{
return await task;
}
return default;
return await action();
}
public async Task<bool> IsDryRunEnabled()
{
var config = await _dataContext.GeneralConfigs
GeneralConfig config = await _dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync();
return config.DryRun;
}
private bool IsDryRun(string? expression)
{
GeneralConfig config = _dataContext.GeneralConfigs
.AsNoTracking()
.First();
if (!config.DryRun)
{
return false;
}
_logger.LogInformation("[DRY RUN] skipping method: {name}", ExtractMethodName(expression));
return true;
}
private async Task<bool> IsDryRunAsync(string? expression)
{
if (!await IsDryRunEnabled())
{
return false;
}
_logger.LogInformation("[DRY RUN] skipping method: {name}", ExtractMethodName(expression));
return true;
}
private static string ExtractMethodName(string? expression)
{
if (string.IsNullOrWhiteSpace(expression))
{
return "unknown";
}
Match match = MethodNameRegex().Match(expression);
return match.Success ? match.Groups[1].Value : expression;
}
}

View File

@@ -1,12 +1,48 @@
namespace Cleanuparr.Infrastructure.Interceptors;
using System.Runtime.CompilerServices;
namespace Cleanuparr.Infrastructure.Interceptors;
/// <summary>
/// Wraps mutating operations so they can be short-circuited when dry-run mode is enabled.
/// Callers pass a lambda; the call-site expression is captured for logging via
/// <see cref="CallerArgumentExpressionAttribute"/> and a method name is extracted from it.
/// </summary>
public interface IDryRunInterceptor
{
void Intercept(Action action);
Task InterceptAsync(Delegate action, params object[] parameters);
/// <summary>
/// Executes <paramref name="action"/> unless dry-run mode is enabled, in which case the
/// operation is skipped and the call is logged.
/// </summary>
/// <param name="action">The synchronous operation to execute.</param>
/// <param name="expression">Auto-populated call-site expression used to log the skipped method name.</param>
void Intercept(
Action action,
[CallerArgumentExpression(nameof(action))] string? expression = null);
Task<T?> InterceptAsync<T>(Delegate action, params object[] parameters);
/// <summary>
/// Awaits <paramref name="action"/> unless dry-run mode is enabled, in which case the
/// operation is skipped and the call is logged.
/// </summary>
/// <param name="action">The asynchronous operation to execute.</param>
/// <param name="expression">Auto-populated call-site expression used to log the skipped method name.</param>
Task InterceptAsync(
Func<Task> action,
[CallerArgumentExpression(nameof(action))] string? expression = null);
/// <summary>
/// Awaits <paramref name="action"/> and returns its result unless dry-run mode is enabled,
/// in which case the operation is skipped and <c>default(T)</c> is returned.
/// </summary>
/// <typeparam name="T">The result type returned by <paramref name="action"/>.</typeparam>
/// <param name="action">The asynchronous operation to execute.</param>
/// <param name="expression">Auto-populated call-site expression used to log the skipped method name.</param>
/// <returns>The result of <paramref name="action"/>, or <c>default</c> when dry-run mode is enabled.</returns>
Task<T?> InterceptAsync<T>(
Func<Task<T>> action,
[CallerArgumentExpression(nameof(action))] string? expression = null);
/// <summary>
/// Returns whether dry-run mode is currently enabled in the persisted general configuration.
/// </summary>
Task<bool> IsDryRunEnabled();
}
}

View File

@@ -0,0 +1,335 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Shouldly;
using Xunit;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Tests.Models.Configuration.DownloadCleaner;
public sealed class OrphanedFilesConfigTests
{
#region Defaults
[Fact]
public void Defaults_EnabledIsFalse()
{
new OrphanedFilesConfig().Enabled.ShouldBeFalse();
}
[Fact]
public void Defaults_ScanDirectoriesIsEmpty()
{
new OrphanedFilesConfig().ScanDirectories.ShouldBeEmpty();
}
[Fact]
public void Defaults_OrphanedDirectoryIsEmpty()
{
new OrphanedFilesConfig().OrphanedDirectory.ShouldBe(string.Empty);
}
[Fact]
public void Defaults_ExcludePatternsIsEmpty()
{
new OrphanedFilesConfig().ExcludePatterns.ShouldBeEmpty();
}
[Fact]
public void Defaults_MinFileAgeHoursIs24()
{
new OrphanedFilesConfig().MinFileAgeHours.ShouldBe(24);
}
[Fact]
public void Defaults_PurgeAfterHoursIsNull()
{
new OrphanedFilesConfig().PurgeAfterHours.ShouldBeNull();
}
#endregion
#region Validate - Self-validation
[Fact]
public void Validate_WhenDisabled_DoesNotThrow()
{
var config = new OrphanedFilesConfig { Enabled = false, ScanDirectories = [] };
Should.NotThrow(() => config.Validate());
}
[Fact]
public void Validate_WhenEnabledWithNoScanDirs_ThrowsValidationException()
{
var config = new OrphanedFilesConfig { Enabled = true, ScanDirectories = [], OrphanedDirectory = "/downloads/orphaned" };
Should.Throw<ValidationException>(() => config.Validate())
.Message.ShouldContain("scan directory");
}
[Fact]
public void Validate_WhenEnabledWithoutOrphanedDirectory_ThrowsValidationException()
{
var config = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/downloads"],
OrphanedDirectory = string.Empty,
};
Should.Throw<ValidationException>(() => config.Validate())
.Message.ShouldContain("Orphaned directory");
}
[Fact]
public void Validate_WhenEnabledWithScanDirsAndOrphanedDir_DoesNotThrow()
{
var config = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/downloads"],
OrphanedDirectory = "/downloads-orphaned",
};
Should.NotThrow(() => config.Validate());
}
#endregion
#region Validate - Cross-client overlap (H1)
[Fact]
public void Validate_ScanDirMatchesSiblingScanDir_ThrowsValidationException()
{
var config = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/downloads/complete"],
OrphanedDirectory = "/downloads/orphaned-a",
};
var sibling = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/downloads/complete"],
OrphanedDirectory = "/downloads/orphaned-b",
};
Should.Throw<ValidationException>(() => config.Validate([sibling]))
.Message.ShouldContain("overlap");
}
[Fact]
public void Validate_ScanDirIsSubpathOfSiblingScanDir_ThrowsValidationException()
{
var config = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/downloads/complete/movies"],
OrphanedDirectory = "/downloads/orphaned-a",
};
var sibling = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/downloads/complete"],
OrphanedDirectory = "/downloads/orphaned-b",
};
Should.Throw<ValidationException>(() => config.Validate([sibling]))
.Message.ShouldContain("overlap");
}
[Fact]
public void Validate_SiblingScanDirIsSubpathOfScanDir_ThrowsValidationException()
{
var config = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/downloads"],
OrphanedDirectory = "/orphaned-a",
};
var sibling = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/downloads/complete"],
OrphanedDirectory = "/orphaned-b",
};
Should.Throw<ValidationException>(() => config.Validate([sibling]))
.Message.ShouldContain("overlap");
}
[Fact]
public void Validate_ScanDirMatchesSiblingOrphanedDir_ThrowsValidationException()
{
var config = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/downloads/orphaned"],
OrphanedDirectory = "/downloads/orphaned-a",
};
var sibling = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/downloads/other"],
OrphanedDirectory = "/downloads/orphaned",
};
Should.Throw<ValidationException>(() => config.Validate([sibling]))
.Message.ShouldContain("overlap");
}
[Fact]
public void Validate_OrphanedDirMatchesSiblingScanDir_ThrowsValidationException()
{
var config = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/downloads/client1"],
OrphanedDirectory = "/downloads/shared",
};
var sibling = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/downloads/shared"],
OrphanedDirectory = "/orphaned-b",
};
Should.Throw<ValidationException>(() => config.Validate([sibling]))
.Message.ShouldContain("overlap");
}
[Fact]
public void Validate_NonOverlappingPaths_DoesNotThrow()
{
var config = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/downloads/client1"],
OrphanedDirectory = "/downloads/orphaned1",
};
var sibling = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/downloads/client2"],
OrphanedDirectory = "/downloads/orphaned2",
};
Should.NotThrow(() => config.Validate([sibling]));
}
[Fact]
public void Validate_PathsWithMixedSeparators_DetectsOverlap()
{
var config = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/downloads/complete"],
OrphanedDirectory = "/orphaned-a",
};
var sibling = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["\\downloads\\complete"],
OrphanedDirectory = "/orphaned-b",
};
Should.Throw<ValidationException>(() => config.Validate([sibling]));
}
[Fact]
public void Validate_EmptySiblingsList_DoesNotThrow()
{
var config = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/downloads/complete"],
OrphanedDirectory = "/orphaned",
};
Should.NotThrow(() => config.Validate([]));
}
#endregion
#region Validate - Cross-client download directory
[Fact]
public void Validate_ScanDirOverlapsOtherClientDownloadTarget_ThrowsValidationException()
{
var config = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/data/downloads"],
OrphanedDirectory = "/data/orphaned",
};
var otherClient = MakeDownloadClient("Other", "/data/downloads/movies");
Should.Throw<ValidationException>(() => config.Validate([], [otherClient]))
.Message.ShouldContain("overlap");
}
[Fact]
public void Validate_OrphanedDirOverlapsOtherClientDownloadTarget_ThrowsValidationException()
{
var config = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/data/downloads"],
OrphanedDirectory = "/data/other-downloads",
};
var otherClient = MakeDownloadClient("Other", "/data/other-downloads/movies");
Should.Throw<ValidationException>(() => config.Validate([], [otherClient]))
.Message.ShouldContain("overlap");
}
[Fact]
public void Validate_NoOverlapWithOtherClientDownloadTarget_DoesNotThrow()
{
var config = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/data/downloads-a"],
OrphanedDirectory = "/data/orphaned",
};
var otherClient = MakeDownloadClient("Other", "/data/downloads-b");
Should.NotThrow(() => config.Validate([], [otherClient]));
}
[Fact]
public void Validate_OtherClientWithoutDownloadTarget_IsIgnored()
{
var config = new OrphanedFilesConfig
{
Enabled = true,
ScanDirectories = ["/data/downloads"],
OrphanedDirectory = "/data/orphaned",
};
var otherClient = MakeDownloadClient("Other", null);
Should.NotThrow(() => config.Validate([], [otherClient]));
}
private static DownloadClientConfig MakeDownloadClient(string name, string? downloadDirectoryTarget) => new()
{
Name = name,
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
DownloadDirectoryTarget = downloadDirectoryTarget,
};
#endregion
}

View File

@@ -7,8 +7,6 @@ namespace Cleanuparr.Persistence.Tests.Models.Configuration.DownloadCleaner;
public sealed class UnlinkedConfigTests
{
#region Default Values
[Fact]
public void Defaults_EnabledIsFalse()
{
@@ -30,24 +28,6 @@ public sealed class UnlinkedConfigTests
config.Categories.ShouldBeEmpty();
}
[Fact]
public void Defaults_DownloadDirectorySourceIsNull()
{
var config = new UnlinkedConfig();
config.DownloadDirectorySource.ShouldBeNull();
}
[Fact]
public void Defaults_DownloadDirectoryTargetIsNull()
{
var config = new UnlinkedConfig();
config.DownloadDirectoryTarget.ShouldBeNull();
}
#endregion
#region Validate - Disabled
[Fact]
public void Validate_WhenDisabled_DoesNotThrow()
{
@@ -61,10 +41,6 @@ public sealed class UnlinkedConfigTests
Should.NotThrow(() => config.Validate());
}
#endregion
#region Validate - Enabled
[Fact]
public void Validate_WhenEnabled_WithValidConfig_DoesNotThrow()
{
@@ -134,76 +110,6 @@ public sealed class UnlinkedConfigTests
exception.Message.ShouldBe("Empty unlinked category filter found");
}
#endregion
#region Validate - Directory Mapping
[Fact]
public void Validate_WhenEnabled_WithOnlySourceSet_ThrowsValidationException()
{
var config = new UnlinkedConfig
{
Enabled = true,
TargetCategory = "cleanuparr-unlinked",
Categories = ["movies"],
DownloadDirectorySource = "/downloads",
DownloadDirectoryTarget = null
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Both download directory source and target must be set, or both must be empty");
}
[Fact]
public void Validate_WhenEnabled_WithOnlyTargetSet_ThrowsValidationException()
{
var config = new UnlinkedConfig
{
Enabled = true,
TargetCategory = "cleanuparr-unlinked",
Categories = ["movies"],
DownloadDirectorySource = null,
DownloadDirectoryTarget = "/data/downloads"
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Both download directory source and target must be set, or both must be empty");
}
[Fact]
public void Validate_WhenEnabled_WithBothDirsSet_DoesNotThrow()
{
var config = new UnlinkedConfig
{
Enabled = true,
TargetCategory = "cleanuparr-unlinked",
Categories = ["movies"],
DownloadDirectorySource = "/downloads",
DownloadDirectoryTarget = "/data/downloads"
};
Should.NotThrow(() => config.Validate());
}
[Fact]
public void Validate_WhenEnabled_WithBothDirsEmpty_DoesNotThrow()
{
var config = new UnlinkedConfig
{
Enabled = true,
TargetCategory = "cleanuparr-unlinked",
Categories = ["movies"],
DownloadDirectorySource = null,
DownloadDirectoryTarget = null
};
Should.NotThrow(() => config.Validate());
}
#endregion
#region Validate - Ignored Root Dirs
[Fact]
public void Validate_WhenEnabled_WithNonExistentIgnoredRootDir_ThrowsValidationException()
{
@@ -244,9 +150,6 @@ public sealed class UnlinkedConfigTests
IgnoredRootDirs = [""]
};
// Empty strings are filtered out, so this should not throw
Should.NotThrow(() => config.Validate());
}
#endregion
}

View File

@@ -108,6 +108,76 @@ public sealed class DownloadClientConfigTests
#endregion
#region Validate - Directory Mapping
[Fact]
public void Validate_WithOnlyDownloadDirectorySourceSet_ThrowsValidationException()
{
var config = new DownloadClientConfig
{
Name = "My Client",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = new Uri("http://localhost:8080"),
DownloadDirectorySource = "/downloads",
DownloadDirectoryTarget = null
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Both download directory source and target must be set, or both must be empty");
}
[Fact]
public void Validate_WithOnlyDownloadDirectoryTargetSet_ThrowsValidationException()
{
var config = new DownloadClientConfig
{
Name = "My Client",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = new Uri("http://localhost:8080"),
DownloadDirectorySource = null,
DownloadDirectoryTarget = "/data/downloads"
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Both download directory source and target must be set, or both must be empty");
}
[Fact]
public void Validate_WithBothDownloadDirectoriesSet_DoesNotThrow()
{
var config = new DownloadClientConfig
{
Name = "My Client",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = new Uri("http://localhost:8080"),
DownloadDirectorySource = "/downloads",
DownloadDirectoryTarget = "/data/downloads"
};
Should.NotThrow(() => config.Validate());
}
[Fact]
public void Validate_WithBothDownloadDirectoriesEmpty_DoesNotThrow()
{
var config = new DownloadClientConfig
{
Name = "My Client",
TypeName = DownloadClientTypeName.qBittorrent,
Type = DownloadClientType.Torrent,
Host = new Uri("http://localhost:8080"),
DownloadDirectorySource = null,
DownloadDirectoryTarget = null
};
Should.NotThrow(() => config.Validate());
}
#endregion
#region Url Property Tests
[Fact]

View File

@@ -76,6 +76,8 @@ public class DataContext : DbContext
public DbSet<BlacklistSyncConfig> BlacklistSyncConfigs { get; set; }
public DbSet<OrphanedFilesConfig> OrphanedFilesConfigs { get; set; }
public DbSet<SeekerConfig> SeekerConfigs { get; set; }
public DbSet<SeekerInstanceConfig> SeekerInstanceConfigs { get; set; }
@@ -369,6 +371,20 @@ public class DataContext : DbContext
entity.HasIndex(u => u.DownloadClientConfigId).IsUnique();
});
// Configure per-client orphaned files config relationship
modelBuilder.Entity<OrphanedFilesConfig>(entity =>
{
entity.HasOne(c => c.DownloadClientConfig)
.WithMany()
.HasForeignKey(c => c.DownloadClientConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(c => c.DownloadClientConfigId).IsUnique();
entity.Property(c => c.ScanDirectories).HasConversion(jsonListConverter);
entity.Property(c => c.ExcludePatterns).HasConversion(jsonListConverter);
});
// Configure BlacklistSyncState relationships and indexes
modelBuilder.Entity<BlacklistSyncHistory>(entity =>
{

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,142 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddOrphanedFilesCleanup : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "download_directory_source",
table: "download_clients",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "download_directory_target",
table: "download_clients",
type: "TEXT",
nullable: true);
// Migrate existing path remapping values from unlinked_configs to download_clients.
// Safe because unlinked_configs has a unique index on download_client_config_id.
migrationBuilder.Sql(@"
UPDATE download_clients
SET
download_directory_source = (
SELECT download_directory_source
FROM unlinked_configs
WHERE unlinked_configs.download_client_config_id = download_clients.id
AND download_directory_source IS NOT NULL
LIMIT 1
),
download_directory_target = (
SELECT download_directory_target
FROM unlinked_configs
WHERE unlinked_configs.download_client_config_id = download_clients.id
AND download_directory_target IS NOT NULL
LIMIT 1
)
WHERE EXISTS (
SELECT 1 FROM unlinked_configs
WHERE unlinked_configs.download_client_config_id = download_clients.id
AND download_directory_source IS NOT NULL
)
");
migrationBuilder.DropColumn(
name: "download_directory_source",
table: "unlinked_configs");
migrationBuilder.DropColumn(
name: "download_directory_target",
table: "unlinked_configs");
migrationBuilder.CreateTable(
name: "orphaned_files_configs",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
download_client_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
enabled = table.Column<bool>(type: "INTEGER", nullable: false),
scan_directories = table.Column<string>(type: "TEXT", nullable: false),
orphaned_directory = table.Column<string>(type: "TEXT", nullable: false),
exclude_patterns = table.Column<string>(type: "TEXT", nullable: false),
min_file_age_hours = table.Column<int>(type: "INTEGER", nullable: false),
purge_after_hours = table.Column<int>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_orphaned_files_configs", x => x.id);
table.ForeignKey(
name: "fk_orphaned_files_configs_download_clients_download_client_config_id",
column: x => x.download_client_config_id,
principalTable: "download_clients",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_orphaned_files_configs_download_client_config_id",
table: "orphaned_files_configs",
column: "download_client_config_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "orphaned_files_configs");
migrationBuilder.AddColumn<string>(
name: "download_directory_source",
table: "unlinked_configs",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "download_directory_target",
table: "unlinked_configs",
type: "TEXT",
nullable: true);
migrationBuilder.Sql(@"
UPDATE unlinked_configs
SET
download_directory_source = (
SELECT download_directory_source
FROM download_clients
WHERE download_clients.id = unlinked_configs.download_client_config_id
AND download_directory_source IS NOT NULL
LIMIT 1
),
download_directory_target = (
SELECT download_directory_target
FROM download_clients
WHERE download_clients.id = unlinked_configs.download_client_config_id
AND download_directory_target IS NOT NULL
LIMIT 1
)
WHERE EXISTS (
SELECT 1 FROM download_clients
WHERE download_clients.id = unlinked_configs.download_client_config_id
AND download_directory_source IS NOT NULL
)
");
migrationBuilder.DropColumn(
name: "download_directory_source",
table: "download_clients");
migrationBuilder.DropColumn(
name: "download_directory_target",
table: "download_clients");
}
}
}

View File

@@ -204,6 +204,54 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.ToTable("download_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.OrphanedFilesConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("DownloadClientConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_client_config_id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("ExcludePatterns")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("exclude_patterns");
b.Property<int>("MinFileAgeHours")
.HasColumnType("INTEGER")
.HasColumnName("min_file_age_hours");
b.Property<string>("OrphanedDirectory")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("orphaned_directory");
b.Property<int?>("PurgeAfterHours")
.HasColumnType("INTEGER")
.HasColumnName("purge_after_hours");
b.Property<string>("ScanDirectories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("scan_directories");
b.HasKey("Id")
.HasName("pk_orphaned_files_configs");
b.HasIndex("DownloadClientConfigId")
.IsUnique()
.HasDatabaseName("ix_orphaned_files_configs_download_client_config_id");
b.ToTable("orphaned_files_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b =>
{
b.Property<Guid>("Id")
@@ -480,14 +528,6 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("download_client_config_id");
b.Property<string>("DownloadDirectorySource")
.HasColumnType("TEXT")
.HasColumnName("download_directory_source");
b.Property<string>("DownloadDirectoryTarget")
.HasColumnType("TEXT")
.HasColumnName("download_directory_target");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
@@ -523,6 +563,14 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("DownloadDirectorySource")
.HasColumnType("TEXT")
.HasColumnName("download_directory_source");
b.Property<string>("DownloadDirectoryTarget")
.HasColumnType("TEXT")
.HasColumnName("download_directory_target");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
@@ -1831,6 +1879,18 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.Navigation("DownloadClientConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.OrphanedFilesConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig")
.WithMany()
.HasForeignKey("DownloadClientConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_orphaned_files_configs_download_clients_download_client_config_id");
b.Navigation("DownloadClientConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig")

View File

@@ -0,0 +1,182 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
/// <summary>
/// Per-download-client configuration for the orphaned files scanner.
/// </summary>
public sealed record OrphanedFilesConfig : IConfig
{
/// <summary>
/// Unique identifier for this config row.
/// </summary>
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// Owning download client identifier.
/// </summary>
public Guid DownloadClientConfigId { get; set; }
/// <summary>
/// Navigation back to the owning download client.
/// </summary>
public DownloadClientConfig DownloadClientConfig { get; set; } = null!;
/// <summary>
/// Whether the orphaned files scanner is enabled for this client.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Absolute paths to scan for orphaned files. Each top-level entry is
/// checked against the client's active torrents.
/// </summary>
public List<string> ScanDirectories { get; set; } = [];
/// <summary>
/// Destination directory where orphaned entries are moved.
/// </summary>
[Required]
public string OrphanedDirectory { get; set; } = string.Empty;
/// <summary>
/// Glob patterns that exclude entries from being treated as orphaned
/// (e.g. "*.nfo", ".DS_Store").
/// </summary>
public List<string> ExcludePatterns { get; set; } = [];
/// <summary>
/// Minimum age in hours an entry must have before it can be considered
/// orphaned. Protects in-flight downloads that the client has not yet
/// registered as a torrent. Set to 0 to disable the age check.
/// </summary>
[Range(0, int.MaxValue)]
public int MinFileAgeHours { get; set; } = 24;
/// <summary>
/// If set, entries in <see cref="OrphanedDirectory"/> older than this many
/// hours are permanently deleted. Null leaves them indefinitely.
/// </summary>
[Range(1, int.MaxValue)]
public int? PurgeAfterHours { get; set; }
/// <summary>
/// Self-validation with no cross-client checks.
/// </summary>
public void Validate() => Validate([], []);
/// <summary>
/// Validates this config and ensures its scan/orphaned paths do not
/// overlap with any sibling client's orphaned-files config or another
/// client's download directory target.
/// </summary>
public void Validate(
IReadOnlyList<OrphanedFilesConfig> siblings,
IReadOnlyList<DownloadClientConfig>? otherDownloadClients = null)
{
otherDownloadClients ??= [];
if (!Enabled)
{
return;
}
if (ScanDirectories.Count == 0)
{
throw new ValidationException("At least one scan directory is required when orphaned files cleanup is enabled for this client");
}
if (string.IsNullOrWhiteSpace(OrphanedDirectory))
{
throw new ValidationException("Orphaned directory is required when orphaned files cleanup is enabled for this client");
}
foreach (var scanDir in ScanDirectories)
{
var normalized = NormalizePath(scanDir);
foreach (var sibling in siblings)
{
foreach (var otherScanDir in sibling.ScanDirectories)
{
CheckOverlap(normalized, NormalizePath(otherScanDir), "scan directory", "another client's scan directory");
}
if (!string.IsNullOrWhiteSpace(sibling.OrphanedDirectory))
{
CheckOverlap(normalized, NormalizePath(sibling.OrphanedDirectory), "scan directory", "another client's orphaned directory");
}
}
foreach (var otherClient in otherDownloadClients)
{
if (!string.IsNullOrWhiteSpace(otherClient.DownloadDirectoryTarget))
{
CheckOverlap(normalized, NormalizePath(otherClient.DownloadDirectoryTarget), "scan directory", $"another client's download directory ({otherClient.Name})");
}
}
}
var normalizedOrphaned = NormalizePath(OrphanedDirectory);
var sep = Path.DirectorySeparatorChar.ToString();
foreach (var scanDir in ScanDirectories)
{
var normalizedScan = NormalizePath(scanDir);
if (string.Equals(normalizedScan, normalizedOrphaned, StringComparison.OrdinalIgnoreCase))
{
throw new ValidationException(
$"Orphaned directory '{normalizedOrphaned}' cannot equal scan directory '{normalizedScan}'.");
}
if (normalizedScan.StartsWith(normalizedOrphaned + sep, StringComparison.OrdinalIgnoreCase))
{
throw new ValidationException(
$"Orphaned directory '{normalizedOrphaned}' cannot be an ancestor of scan directory '{normalizedScan}'.");
}
}
foreach (var sibling in siblings)
{
foreach (var otherScanDir in sibling.ScanDirectories)
{
CheckOverlap(normalizedOrphaned, NormalizePath(otherScanDir), "orphaned directory", "another client's scan directory");
}
if (!string.IsNullOrWhiteSpace(sibling.OrphanedDirectory))
{
CheckOverlap(normalizedOrphaned, NormalizePath(sibling.OrphanedDirectory), "orphaned directory", "another client's orphaned directory");
}
}
foreach (var otherClient in otherDownloadClients)
{
if (!string.IsNullOrWhiteSpace(otherClient.DownloadDirectoryTarget))
{
CheckOverlap(normalizedOrphaned, NormalizePath(otherClient.DownloadDirectoryTarget), "orphaned directory", $"another client's download directory ({otherClient.Name})");
}
}
}
private static void CheckOverlap(string a, string b, string aLabel, string bLabel)
{
var sep = Path.DirectorySeparatorChar.ToString();
if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase)
|| a.StartsWith(b + sep, StringComparison.OrdinalIgnoreCase)
|| b.StartsWith(a + sep, StringComparison.OrdinalIgnoreCase))
{
throw new ValidationException(
$"Path overlap detected: {aLabel} '{a}' overlaps with {bLabel} '{b}'. Scan directories and orphaned directories must not overlap across clients.");
}
}
private static string NormalizePath(string path) =>
string.Join(Path.DirectorySeparatorChar, path.Split(['\\', '/']))
.TrimEnd(Path.DirectorySeparatorChar);
}

View File

@@ -24,18 +24,6 @@ public sealed record UnlinkedConfig : IConfig
public List<string> Categories { get; set; } = [];
/// <summary>
/// The path prefix reported by the download client (e.g., "/downloads").
/// When set, this prefix is replaced with <see cref="DownloadDirectoryTarget"/> when resolving file paths.
/// </summary>
public string? DownloadDirectorySource { get; set; }
/// <summary>
/// The actual local mount path (e.g., "/downloads-other").
/// Replaces <see cref="DownloadDirectorySource"/> in file paths for hardlink checking.
/// </summary>
public string? DownloadDirectoryTarget { get; set; }
public void Validate()
{
if (!Enabled)
@@ -63,11 +51,6 @@ public sealed record UnlinkedConfig : IConfig
throw new ValidationException("Empty unlinked category filter found");
}
if (!string.IsNullOrEmpty(DownloadDirectorySource) != !string.IsNullOrEmpty(DownloadDirectoryTarget))
{
throw new ValidationException("Both download directory source and target must be set, or both must be empty");
}
foreach (var dir in IgnoredRootDirs.Where(d => !string.IsNullOrEmpty(d)))
{
if (!Directory.Exists(dir))

View File

@@ -78,6 +78,18 @@ public sealed record DownloadClientConfig
[JsonIgnore]
public Uri ExternalOrInternalUrl => ExternalUrl ?? Url;
/// <summary>
/// The path prefix reported by the download client (e.g., "/downloads").
/// Replaced with <see cref="DownloadDirectoryTarget"/> when resolving file paths across all features.
/// </summary>
public string? DownloadDirectorySource { get; set; }
/// <summary>
/// The actual local mount path (e.g., "/data/downloads").
/// Replaces <see cref="DownloadDirectorySource"/> in file paths for hardlink checking and orphan detection.
/// </summary>
public string? DownloadDirectoryTarget { get; set; }
/// <summary>
/// Validates the configuration
/// </summary>
@@ -87,10 +99,15 @@ public sealed record DownloadClientConfig
{
throw new ValidationException($"Client name cannot be empty");
}
if (Host is null)
{
throw new ValidationException($"Host cannot be empty");
}
if (!string.IsNullOrWhiteSpace(DownloadDirectorySource) != !string.IsNullOrWhiteSpace(DownloadDirectoryTarget))
{
throw new ValidationException("Both download directory source and target must be set, or both must be empty");
}
}
}

View File

@@ -21,7 +21,8 @@ public static class PathHelper
return filePath;
}
var normSource = source.TrimEnd('/', '\\') + Path.DirectorySeparatorChar;
// Normalize separators so Windows source paths (backslashes) match Linux-normalized filePaths
var normSource = source.Replace('\\', Path.DirectorySeparatorChar).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
var normTarget = target.TrimEnd('/', '\\');
// Exact match: filePath is exactly the source directory (no trailing separator)
@@ -38,4 +39,14 @@ public static class PathHelper
return filePath;
}
/// <summary>
/// Normalizes path separators to the host's <see cref="Path.DirectorySeparatorChar"/> and then
/// applies <see cref="RemapPath"/>.
/// </summary>
public static string NormalizeAndRemap(string path, string? source, string? target)
{
string normalized = string.Join(Path.DirectorySeparatorChar, path.Split(['\\', '/']));
return RemapPath(normalized, source, target);
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -53,6 +53,7 @@ import {
tablerGripVertical,
tablerFilter,
tablerPalette,
tablerFileOff,
} from '@ng-icons/tabler-icons';
import { routes } from './app.routes';
@@ -116,6 +117,7 @@ export const appConfig: ApplicationConfig = {
tablerGripVertical,
tablerFilter,
tablerPalette,
tablerFileOff,
}),
],
};

View File

@@ -1,7 +1,12 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { DownloadCleanerConfig, SeedingRule, UnlinkedConfigModel } from '@shared/models/download-cleaner-config.model';
import {
DownloadCleanerConfig,
SeedingRule,
UnlinkedConfigModel,
OrphanedFilesConfig,
} from '@shared/models/download-cleaner-config.model';
@Injectable({ providedIn: 'root' })
export class DownloadCleanerApi {
@@ -37,11 +42,12 @@ export class DownloadCleanerApi {
}
// Unlinked config
getUnlinkedConfig(clientId: string): Observable<UnlinkedConfigModel | null> {
return this.http.get<UnlinkedConfigModel | null>(`/api/unlinked-config/${clientId}`);
}
updateUnlinkedConfig(clientId: string, config: Partial<UnlinkedConfigModel>): Observable<void> {
return this.http.put<void>(`/api/unlinked-config/${clientId}`, config);
}
// Per-client orphaned files config
updateOrphanedFilesConfig(clientId: string, config: Partial<OrphanedFilesConfig>): Observable<OrphanedFilesConfig> {
return this.http.put<OrphanedFilesConfig>(`/api/orphaned-files-config/${clientId}`, config);
}
}

View File

@@ -89,6 +89,12 @@ export class DocumentationService {
'downloadDirectoryTarget': 'download-directory-source-and-local-directory-target',
'unlinkedIgnoredRootDir': 'ignored-root-directory',
'unlinkedCategories': 'unlinked-categories',
'orphanedFilesEnabled': 'enabled-per-client',
'orphanedFilesScanDirectories': 'scan-directories',
'orphanedFilesOrphanedDirectory': 'orphaned-directory',
'orphanedFilesExcludePatterns': 'exclude-patterns',
'orphanedFilesMinFileAgeHours': 'min-file-age',
'orphanedFilesPurgeAfterHours': 'purge-orphaned-after',
},
'malware-blocker': {
'enabled': 'enable-malware-blocker',

View File

@@ -55,7 +55,7 @@
}
<div class="form-actions">
<app-button variant="primary" [glowing]="dirty()" [loading]="saving()" [disabled]="saving() || saved() || hasGlobalErrors() || !dirty()" (clicked)="save()">
{{ saved() ? 'Saved!' : 'Save Settings' }}
{{ saved() ? 'Saved!' : 'Save' }}
</app-button>
</div>
</div>
@@ -178,19 +178,6 @@
<div class="form-divider"></div>
<app-input label="Download Directory (Source)" placeholder="/downloads"
[value]="client.unlinkedConfig?.downloadDirectorySource ?? ''"
(valueChange)="updateUnlinkedField('downloadDirectorySource', $event)"
hint="The path prefix as reported by the download client (e.g. /downloads)"
helpKey="download-cleaner:downloadDirectorySource" />
<app-input label="Local Directory (Target)" placeholder="/downloads-other"
[value]="client.unlinkedConfig?.downloadDirectoryTarget ?? ''"
(valueChange)="updateUnlinkedField('downloadDirectoryTarget', $event)"
hint="The actual local mount path that replaces the source prefix (e.g. /downloads-other)"
helpKey="download-cleaner:downloadDirectoryTarget" />
<div class="form-divider"></div>
<app-chip-input label="Ignored Root Directories" placeholder="Add directory path..."
[items]="client.unlinkedConfig?.ignoredRootDirs ?? []"
(itemsChange)="updateUnlinkedField('ignoredRootDirs', $event)"
@@ -205,7 +192,77 @@
}
<div class="form-actions">
<app-button variant="primary" [glowing]="unlinkedDirty()" [loading]="unlinkedSaving()" [disabled]="unlinkedSaving() || unlinkedSaved() || !unlinkedDirty() || !!unlinkedCategoriesError()" (clicked)="saveUnlinkedConfig()">
{{ unlinkedSaved() ? 'Saved!' : 'Save Unlinked Config' }}
{{ unlinkedSaved() ? 'Saved!' : 'Save' }}
</app-button>
</div>
</div>
</app-accordion>
<app-accordion header="Orphaned Files" subtitle="Move files not associated with any active torrent" [(expanded)]="orphanedFilesExpanded">
<div class="form-stack">
<app-toggle
label="Enabled"
[checked]="client.orphanedFilesConfig?.enabled ?? false"
(checkedChange)="updateOrphanedFilesField('enabled', $event)"
hint="Enable orphaned files scanning for this download client"
helpKey="download-cleaner:orphanedFilesEnabled"
/>
@if (client.orphanedFilesConfig?.enabled) {
<div class="form-divider"></div>
<app-chip-input
label="Scan Directories"
placeholder="Add directory path..."
hint="Absolute paths to scan for orphaned files. Each top-level entry is checked against your active torrents."
[items]="client.orphanedFilesConfig?.scanDirectories ?? []"
(itemsChange)="updateOrphanedFilesField('scanDirectories', $event)"
[error]="orphanedFilesScanDirsError()"
helpKey="download-cleaner:orphanedFilesScanDirectories"
/>
<app-input
label="Orphaned Directory"
placeholder="/mnt/data/orphaned"
hint="Where orphaned files are moved."
[value]="client.orphanedFilesConfig?.orphanedDirectory ?? ''"
(valueChange)="updateOrphanedFilesField('orphanedDirectory', $event)"
[error]="orphanedFilesOrphanedDirError()"
helpKey="download-cleaner:orphanedFilesOrphanedDirectory"
/>
<app-chip-input
label="Exclude Patterns"
placeholder="Add glob pattern (e.g. *.nfo)..."
hint="File or directory names matching these patterns are never considered orphaned (e.g. *.nfo, .DS_Store)"
[items]="client.orphanedFilesConfig?.excludePatterns ?? []"
(itemsChange)="updateOrphanedFilesField('excludePatterns', $event)"
helpKey="download-cleaner:orphanedFilesExcludePatterns"
/>
<app-number-input
label="Min File Age"
suffix="hours"
[min]="0"
hint="Skip files or folders modified less than this many hours ago. Protects active downloads. Set to 0 to disable the age check."
[value]="client.orphanedFilesConfig?.minFileAgeHours ?? 24"
(valueChange)="updateOrphanedFilesField('minFileAgeHours', $event ?? 24)"
helpKey="download-cleaner:orphanedFilesMinFileAgeHours"
/>
<app-number-input
label="Purge Orphaned After"
suffix="hours"
[min]="1"
hint="Permanently delete entries from the Orphaned Directory after this many hours. Leave empty to keep them indefinitely."
[value]="client.orphanedFilesConfig?.purgeAfterHours ?? null"
(valueChange)="updateOrphanedFilesField('purgeAfterHours', $event ?? undefined)"
helpKey="download-cleaner:orphanedFilesPurgeAfterHours"
/>
}
<div class="form-actions">
<app-button
variant="primary"
[glowing]="orphanedFilesDirty()"
[loading]="orphanedFilesSaving()"
[disabled]="orphanedFilesSaving() || orphanedFilesSaved() || !orphanedFilesDirty() || !!orphanedFilesScanDirsError() || !!orphanedFilesOrphanedDirError()"
(clicked)="saveOrphanedFilesConfig()"
>
{{ orphanedFilesSaved() ? 'Saved!' : 'Save' }}
</app-button>
</div>
</div>

View File

@@ -15,7 +15,8 @@ import { ToastService } from '@core/services/toast.service';
import { ConfirmService } from '@core/services/confirm.service';
import {
DownloadCleanerConfig, SeedingRule, ClientCleanerConfig, UnlinkedConfigModel,
createDefaultUnlinkedConfig,
OrphanedFilesConfig,
createDefaultUnlinkedConfig, createDefaultOrphanedFilesConfig,
} from '@shared/models/download-cleaner-config.model';
import { ScheduleOptions } from '@shared/models/queue-cleaner-config.model';
import { ScheduleUnit, TorrentPrivacyType, DownloadClientTypeName } from '@shared/models/enums';
@@ -62,6 +63,7 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
);
private readonly savedSnapshot = signal('');
private readonly orphanedFilesSnapshots = signal<Record<string, string>>({});
readonly scheduleUnitOptions = SCHEDULE_UNIT_OPTIONS;
readonly privacyTypeOptions = PRIVACY_TYPE_OPTIONS;
@@ -71,6 +73,8 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
readonly saved = signal(false);
readonly unlinkedSaving = signal(false);
readonly unlinkedSaved = signal(false);
readonly orphanedFilesSaving = signal(false);
readonly orphanedFilesSaved = signal(false);
readonly rulesReloading = signal(false);
private readonly unlinkedSnapshots = signal<Record<string, string>>({});
@@ -115,6 +119,7 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
readonly seedingRulesExpanded = signal(false);
readonly unlinkedExpanded = signal(false);
readonly orphanedFilesExpanded = signal(false);
// Seeding rule modal
readonly ruleModalVisible = signal(false);
@@ -148,25 +153,35 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
}
readonly scheduleEveryError = computed(() => {
if (this.useAdvancedScheduling()) return undefined;
if (this.useAdvancedScheduling()) {
return undefined;
}
const unit = this.scheduleUnit() as ScheduleUnit;
const options = ScheduleOptions[unit] ?? [];
if (!options.includes(this.scheduleEvery() as number)) return 'Please select a value';
if (!options.includes(this.scheduleEvery() as number)) {
return 'Please select a value';
}
return undefined;
});
readonly cronError = computed(() => {
if (this.useAdvancedScheduling() && !this.cronExpression().trim()) return 'Cron expression is required';
if (this.useAdvancedScheduling() && !this.cronExpression().trim()) {
return 'Cron expression is required';
}
return undefined;
});
readonly ruleNameError = computed(() => {
if (!this.ruleName().trim()) return 'Name is required';
if (!this.ruleName().trim()) {
return 'Name is required';
}
return undefined;
});
readonly ruleCategoriesError = computed(() => {
if (this.ruleCategories().length === 0) return 'At least one category is required';
if (this.ruleCategories().length === 0) {
return 'At least one category is required';
}
return undefined;
});
@@ -179,25 +194,67 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
readonly unlinkedCategoriesError = computed(() => {
const client = this.selectedClient();
if (!client?.unlinkedConfig?.enabled) return undefined;
if (!client?.unlinkedConfig?.enabled) {
return undefined;
}
if ((client.unlinkedConfig.categories ?? []).length === 0) {
return 'At least one category is required';
}
return undefined;
});
readonly orphanedFilesScanDirsError = computed(() => {
const client = this.selectedClient();
if (!client?.orphanedFilesConfig?.enabled) {
return undefined;
}
if ((client.orphanedFilesConfig.scanDirectories ?? []).length === 0) {
return 'At least one scan directory is required';
}
return undefined;
});
readonly orphanedFilesOrphanedDirError = computed(() => {
const client = this.selectedClient();
if (!client?.orphanedFilesConfig?.enabled) {
return undefined;
}
if (!client.orphanedFilesConfig.orphanedDirectory?.trim()) {
return 'Orphaned directory is required';
}
return undefined;
});
readonly unlinkedDirty = computed(() => {
const client = this.selectedClient();
if (!client) return false;
const saved = this.unlinkedSnapshots()[client.downloadClientId];
if (!saved) return false;
if (!client) {
return false;
}
const saved = this.unlinkedSnapshots()[client.downloadClientId]
?? JSON.stringify(createDefaultUnlinkedConfig());
return saved !== JSON.stringify(client.unlinkedConfig);
});
readonly orphanedFilesDirty = computed(() => {
const client = this.selectedClient();
if (!client) {
return false;
}
const saved = this.orphanedFilesSnapshots()[client.downloadClientId]
?? JSON.stringify(createDefaultOrphanedFilesConfig());
return saved !== JSON.stringify(client.orphanedFilesConfig);
});
readonly hasGlobalErrors = computed(() => {
if (this.scheduleEveryError()) return true;
if (this.cronError()) return true;
if (this.chipInputs().some(c => c.hasUncommittedInput())) return true;
if (this.scheduleEveryError()) {
return true;
}
if (this.cronError()) {
return true;
}
if (this.chipInputs().some(c => c.hasUncommittedInput())) {
return true;
}
return false;
});
@@ -210,35 +267,43 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
private loadConfig(): void {
this.loader.start();
this.api.getConfig().subscribe({
next: (config) => {
this.config = config;
this.enabled.set(config.enabled);
this.useAdvancedScheduling.set(config.useAdvancedScheduling);
this.cronExpression.set(config.cronExpression);
const parsed = parseCronToJobSchedule(config.cronExpression);
next: (dc) => {
this.config = dc;
this.enabled.set(dc.enabled);
this.useAdvancedScheduling.set(dc.useAdvancedScheduling);
this.cronExpression.set(dc.cronExpression);
const parsed = parseCronToJobSchedule(dc.cronExpression);
if (parsed) {
this.scheduleEvery.set(parsed.every);
this.scheduleUnit.set(parsed.type);
}
this.ignoredDownloads.set(config.ignoredDownloads ?? []);
this.clientConfigs.set((config.clients ?? []).map(c => ({
this.ignoredDownloads.set(dc.ignoredDownloads ?? []);
this.clientConfigs.set((dc.clients ?? []).map(c => ({
...c,
seedingRules: c.seedingRules ?? [],
unlinkedConfig: c.unlinkedConfig ?? createDefaultUnlinkedConfig(),
orphanedFilesConfig: c.orphanedFilesConfig ?? createDefaultOrphanedFilesConfig(),
})));
if (config.clients?.length > 0) {
this.selectedClientId.set(config.clients[0].downloadClientId);
if (dc.clients?.length > 0) {
this.selectedClientId.set(dc.clients[0].downloadClientId);
}
// Save unlinked config snapshots per client
const snapshots: Record<string, string> = {};
for (const c of config.clients ?? []) {
snapshots[c.downloadClientId] = JSON.stringify(c.unlinkedConfig ?? createDefaultUnlinkedConfig());
const unlinkedSnapshots: Record<string, string> = {};
const orphanedFilesSnapshots: Record<string, string> = {};
for (const c of dc.clients ?? []) {
unlinkedSnapshots[c.downloadClientId] = JSON.stringify(c.unlinkedConfig ?? createDefaultUnlinkedConfig());
orphanedFilesSnapshots[c.downloadClientId] = JSON.stringify(c.orphanedFilesConfig ?? createDefaultOrphanedFilesConfig());
}
this.unlinkedSnapshots.set(snapshots);
this.unlinkedSnapshots.set(unlinkedSnapshots);
this.orphanedFilesSnapshots.set(orphanedFilesSnapshots);
this.loader.stop();
// Defer snapshot so constructor effects (e.g. schedule unit clamping) settle first
queueMicrotask(() => this.savedSnapshot.set(this.buildSnapshot()));
queueMicrotask(() => {
this.savedSnapshot.set(this.buildSnapshot());
});
},
error: () => {
this.toast.error('Failed to load download cleaner settings');
@@ -284,9 +349,13 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
}
saveRule(): void {
if (this.ruleNameError() || this.ruleCategoriesError() || this.ruleDisabledError() || this.ruleHasUncommittedInputs()) return;
if (this.ruleNameError() || this.ruleCategoriesError() || this.ruleDisabledError() || this.ruleHasUncommittedInputs()) {
return;
}
const clientId = this.selectedClientId();
if (!clientId) return;
if (!clientId) {
return;
}
const sanitize = (list: string[]) => list.map(s => s.trim()).filter(s => s.length > 0);
@@ -325,9 +394,13 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
confirmLabel: 'Delete',
destructive: true,
});
if (!confirmed || !rule.id) return;
if (!confirmed || !rule.id) {
return;
}
const clientId = this.selectedClientId();
if (!clientId) return;
if (!clientId) {
return;
}
this.api.deleteSeedingRule(rule.id).subscribe({
next: () => {
@@ -340,7 +413,9 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
onRulesReorder(event: CdkDragDrop<SeedingRule[]>): void {
const clientId = this.selectedClientId();
if (!clientId) return;
if (!clientId) {
return;
}
const rules = [...(this.selectedClient()?.seedingRules ?? [])];
moveItemInArray(rules, event.previousIndex, event.currentIndex);
@@ -375,14 +450,16 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
}
async onClientChange(newClientId: unknown): Promise<void> {
if (this.unlinkedDirty()) {
if (this.unlinkedDirty() || this.orphanedFilesDirty()) {
const confirmed = await this.confirm.confirm({
title: 'Unsaved Changes',
message: 'You have unsaved unlinked config changes. Discard them?',
message: 'You have unsaved changes for this client. Discard them?',
confirmLabel: 'Discard',
destructive: true,
});
if (!confirmed) return;
if (!confirmed) {
return;
}
}
this.selectedClientId.set(newClientId as string | null);
}
@@ -402,7 +479,9 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
saveUnlinkedConfig(): void {
const clientId = this.selectedClientId();
const client = this.selectedClient();
if (!clientId || !client?.unlinkedConfig) return;
if (!clientId || !client?.unlinkedConfig) {
return;
}
this.unlinkedSaving.set(true);
this.api.updateUnlinkedConfig(clientId, client.unlinkedConfig).subscribe({
@@ -411,7 +490,6 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
this.unlinkedSaving.set(false);
this.unlinkedSaved.set(true);
setTimeout(() => this.unlinkedSaved.set(false), 1500);
// Update snapshot for this client
this.unlinkedSnapshots.update(s => ({
...s,
[clientId]: JSON.stringify(client.unlinkedConfig),
@@ -424,9 +502,48 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
});
}
// --- Orphaned files per-client config ---
updateOrphanedFilesField<K extends keyof OrphanedFilesConfig>(field: K, value: OrphanedFilesConfig[K]): void {
this.updateSelectedClient(client => ({
...client,
orphanedFilesConfig: {
...(client.orphanedFilesConfig ?? createDefaultOrphanedFilesConfig()),
[field]: value,
},
}));
}
saveOrphanedFilesConfig(): void {
const clientId = this.selectedClientId();
const client = this.selectedClient();
if (!clientId || !client?.orphanedFilesConfig) {
return;
}
this.orphanedFilesSaving.set(true);
this.api.updateOrphanedFilesConfig(clientId, client.orphanedFilesConfig).subscribe({
next: () => {
this.toast.success('Orphaned files settings saved');
this.orphanedFilesSaving.set(false);
this.orphanedFilesSaved.set(true);
setTimeout(() => this.orphanedFilesSaved.set(false), 1500);
this.orphanedFilesSnapshots.update(s => ({
...s,
[clientId]: JSON.stringify(client.orphanedFilesConfig),
}));
},
error: (err: ApiError) => {
this.toast.error(err.statusCode === 400 ? err.message : 'Failed to save orphaned files settings');
this.orphanedFilesSaving.set(false);
},
});
}
private updateSelectedClient(updater: (client: ClientCleanerConfig) => ClientCleanerConfig): void {
const id = this.selectedClientId();
if (!id) return;
if (!id) {
return;
}
this.clientConfigs.update(configs =>
configs.map(c => c.downloadClientId === id ? updater(c) : c)
);
@@ -435,7 +552,9 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
// --- Global config save ---
save(): void {
if (!this.config) return;
if (!this.config) {
return;
}
const jobSchedule = { every: (this.scheduleEvery() as number) ?? 5, type: this.scheduleUnit() as ScheduleUnit };
const cronExpression = this.useAdvancedScheduling()
@@ -484,6 +603,6 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
});
hasPendingChanges(): boolean {
return this.dirty() || this.unlinkedDirty();
return this.dirty() || this.unlinkedDirty() || this.orphanedFilesDirty();
}
}

View File

@@ -87,6 +87,12 @@
<app-input label="External URL" placeholder="https://qbit.example.com" type="url" [(value)]="modalExternalUrl"
hint="Optional URL used in notifications for clickable links (e.g., when internal Docker URLs are not reachable externally)"
helpKey="download-client:externalUrl" />
<app-input label="Download Directory Source" placeholder="/downloads" [(value)]="modalDownloadDirectorySource"
hint="Path prefix reported by the download client. Set when paths differ between the client's container and Cleanuparr (e.g. /downloads)"
helpKey="download-client:downloadDirectorySource" />
<app-input label="Download Directory Target" placeholder="/mnt/data/downloads" [(value)]="modalDownloadDirectoryTarget"
hint="Actual path on the filesystem seen by Cleanuparr, replacing the source prefix (e.g. /mnt/data/downloads)"
helpKey="download-client:downloadDirectoryTarget" />
</div>
<div modal-footer>
<app-button variant="secondary" size="sm" [loading]="testing()" (clicked)="testConnection()">

View File

@@ -57,15 +57,21 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges {
readonly modalPassword = signal('');
readonly modalUrlBase = signal('');
readonly modalExternalUrl = signal('');
readonly modalDownloadDirectorySource = signal('');
readonly modalDownloadDirectoryTarget = signal('');
readonly testing = signal(false);
// Modal validation
readonly modalNameError = computed(() => {
if (!this.modalName().trim()) return 'Name is required';
if (!this.modalName().trim()) {
return 'Name is required';
}
return undefined;
});
readonly modalHostError = computed(() => {
if (!this.modalHost().trim()) return 'Host is required';
if (!this.modalHost().trim()) {
return 'Host is required';
}
return undefined;
});
readonly hasModalErrors = computed(() => !!(
@@ -146,6 +152,8 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges {
this.modalPassword.set('');
this.modalUrlBase.set('');
this.modalExternalUrl.set('');
this.modalDownloadDirectorySource.set('');
this.modalDownloadDirectoryTarget.set('');
this.modalVisible.set(true);
}
@@ -159,6 +167,8 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges {
this.modalPassword.set(client.password ?? '');
this.modalUrlBase.set(client.urlBase);
this.modalExternalUrl.set(client.externalUrl ?? '');
this.modalDownloadDirectorySource.set(client.downloadDirectorySource ?? '');
this.modalDownloadDirectoryTarget.set(client.downloadDirectoryTarget ?? '');
this.modalVisible.set(true);
}
@@ -186,7 +196,9 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges {
}
saveClient(): void {
if (this.hasModalErrors()) return;
if (this.hasModalErrors()) {
return;
}
const editing = this.editingClient();
this.saving.set(true);
@@ -201,6 +213,8 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges {
password: this.modalPassword() || undefined,
urlBase: this.modalUrlBase(),
externalUrl: this.modalExternalUrl() || undefined,
downloadDirectorySource: this.modalDownloadDirectorySource() || null,
downloadDirectoryTarget: this.modalDownloadDirectoryTarget() || null,
};
this.api.update(editing.id, client).subscribe({
next: () => {
@@ -225,6 +239,8 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges {
password: this.modalPassword(),
urlBase: this.modalUrlBase(),
externalUrl: this.modalExternalUrl() || undefined,
downloadDirectorySource: this.modalDownloadDirectorySource() || null,
downloadDirectoryTarget: this.modalDownloadDirectoryTarget() || null,
};
this.api.create(dto).subscribe({
next: () => {
@@ -248,7 +264,9 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges {
confirmLabel: 'Delete',
destructive: true,
});
if (!confirmed) return;
if (!confirmed) {
return;
}
this.api.delete(client.id).subscribe({
next: () => {

View File

@@ -21,8 +21,15 @@ export interface UnlinkedConfigModel {
useTag: boolean;
ignoredRootDirs: string[];
categories: string[];
downloadDirectorySource: string | null;
downloadDirectoryTarget: string | null;
}
export interface OrphanedFilesConfig {
enabled: boolean;
scanDirectories: string[];
orphanedDirectory: string;
excludePatterns: string[];
minFileAgeHours: number;
purgeAfterHours?: number;
}
export interface ClientCleanerConfig {
@@ -32,6 +39,7 @@ export interface ClientCleanerConfig {
downloadClientTypeName: string;
seedingRules: SeedingRule[];
unlinkedConfig: UnlinkedConfigModel | null;
orphanedFilesConfig: OrphanedFilesConfig | null;
}
export interface DownloadCleanerConfig {
@@ -65,7 +73,15 @@ export function createDefaultUnlinkedConfig(): UnlinkedConfigModel {
useTag: false,
ignoredRootDirs: [],
categories: [],
downloadDirectorySource: null,
downloadDirectoryTarget: null,
};
}
export function createDefaultOrphanedFilesConfig(): OrphanedFilesConfig {
return {
enabled: false,
scanDirectories: [],
orphanedDirectory: '',
excludePatterns: [],
minFileAgeHours: 24,
};
}

View File

@@ -11,6 +11,8 @@ export interface ClientConfig {
password?: string;
urlBase: string;
externalUrl?: string;
downloadDirectorySource?: string | null;
downloadDirectoryTarget?: string | null;
}
export interface DownloadClientConfig {
@@ -27,6 +29,8 @@ export interface CreateDownloadClientDto {
password?: string;
urlBase?: string;
externalUrl?: string;
downloadDirectorySource?: string | null;
downloadDirectoryTarget?: string | null;
}
export interface TestDownloadClientRequest {