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

@@ -44,7 +44,7 @@ jobs:
- name: Start services
working-directory: e2e
run: docker compose -f docker-compose.e2e.yml up -d --build
run: make up
env:
PACKAGES_USERNAME: ${{ github.repository_owner }}
PACKAGES_PAT: ${{ env.PACKAGES_PAT }}

View File

@@ -34,6 +34,7 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
> - Search for **custom format score upgrades** with automatic score tracking.
> - Clean up downloads that have been **seeding** for a certain amount of time.
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).
> - Scan configured directories for **files not claimed by any active torrent**, move them to a dedicated orphaned directory, and optionally auto-purge.
> - Notify on strike or download removal.
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.

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 {

View File

@@ -138,6 +138,18 @@ Advanced download management and automation features for your *arr applications
</ConfigSection>
<ConfigSection
id="orphaned-files-scanner"
title="Orphaned Files Scanner"
description="Find files on disk that aren't claimed by any active torrent and move them aside"
icon="file-off"
>
- Scan configured directories for **files and folders not referenced by any active torrent** across all download clients.
- Move detected orphans to a dedicated **Orphaned Directory** for later review.
</ConfigSection>
<ConfigSection
id="notifications"
title="Notification System"

View File

@@ -208,6 +208,113 @@ When enabled, the source files will be deleted from disk when the download is re
<div className={styles.section}>
<SectionTitle>Orphaned Files</SectionTitle>
<p className={styles.sectionDescription}>
The Orphaned Files feature scans configured directories for files and directories no longer tracked by any active torrent and moves them to a dedicated orphaned directory. It runs as part of the Download Cleaner job and shares its schedule. Supported for all download clients: qBittorrent, Transmission, Deluge, rTorrent, and uTorrent.
</p>
<p className={styles.sectionDescription}>
All Orphaned Files settings are configured per download client in the <strong>Orphaned Files</strong> accordion inside the per-client section.
</p>
<ConfigSection
title="Exclude Patterns"
>
Glob patterns for file or directory names that should never be considered orphaned, even if no active torrent claims them. Matching is case-insensitive and applied to the entry name only, not the full path.
**Examples:**
```
*.nfo
*.txt
.DS_Store
Thumbs.db
```
<Note>
Use `*` as a wildcard for any characters. For example, `*.nfo` matches any file ending in `.nfo` regardless of its location inside the scan directory.
</Note>
</ConfigSection>
<ConfigSection
title="Min File Age"
>
Minimum age in hours a file or directory must have before it can be considered orphaned. This protects files that are actively being downloaded or have just finished — they may not yet be registered as a torrent save path.
Defaults to `24` hours. Set to `0` to disable the age check.
**Example:** Set to `1` to skip any entry modified less than an hour ago.
</ConfigSection>
<ConfigSection
title="Purge Orphaned After"
>
Number of hours after which entries in the Orphaned Directory are permanently deleted. Leave empty to keep orphaned files indefinitely.
**Example:** Set to `720` to automatically purge orphaned files older than 30 days.
<Warning>
Files deleted by this option are permanently removed from disk. Review the contents of your Orphaned Directory before enabling this setting.
</Warning>
</ConfigSection>
<ConfigSection
title="Enabled (per client)"
>
Enable orphaned files scanning for this specific download client. The download client must also be enabled in Download Client settings.
</ConfigSection>
<ConfigSection
title="Scan Directories"
>
Absolute paths to scan for orphaned files for this download client. Each top-level entry inside these directories is checked against the save paths of all active torrents across all enabled clients. If no active torrent claims an entry, it is considered orphaned.
**Examples:**
```
/data/downloads/completed
/data/downloads/cross-seed
```
<Important>
Each path must be accessible by Cleanuparr. If running in Docker, make sure to mount the directories accordingly.
</Important>
<Important>
Scan directories must not overlap (be equal to, a parent of, or a subdirectory of) the scan directories or orphaned directory of any other download client. Cleanuparr enforces this at save time to prevent cross-client false positives.
</Important>
</ConfigSection>
<ConfigSection
title="Orphaned Directory"
>
The directory where orphaned files and directories are moved for this download client. Required when the orphaned files cleanup is enabled.
**Example:**
```
/data/downloads/orphaned
```
<Note>
The orphaned directory itself is never scanned for orphans. If the destination already contains an entry with the same name, a timestamp suffix is appended automatically to avoid collisions.
</Note>
</ConfigSection>
</div>
<div className={styles.section}>
<SectionTitle>Unlinked Download Settings</SectionTitle>
<p className={styles.sectionDescription}>

1
e2e/.gitignore vendored
View File

@@ -2,3 +2,4 @@ node_modules/
test-results/
playwright-report/
blob-report/
test-data/

View File

@@ -1,10 +1,13 @@
.PHONY: up down test install
.PHONY: up down test install setup
up:
docker compose -f docker-compose.e2e.yml up -d --build
setup:
bash ./scripts/setup-test-data.sh
up: setup
docker compose -f docker-compose.e2e.yml up -d --build --remove-orphans
down:
docker compose -f docker-compose.e2e.yml down
docker compose -f docker-compose.e2e.yml down -v
install:
npm install

View File

@@ -35,6 +35,8 @@ services:
HTTP_PORTS: "5000"
tmpfs:
- /config
volumes:
- ./test-data/downloads:/e2e-downloads
nginx:
image: nginx:1.27-alpine
@@ -43,3 +45,100 @@ services:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- app
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:4.6.7
network_mode: host
environment:
PUID: "1000"
PGID: "1000"
TZ: "UTC"
WEBUI_PORT: "8090"
TORRENTING_PORT: "6881"
volumes:
- ./test-data/qbittorrent-config:/config
- ./test-data/downloads/qbittorrent:/downloads
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://localhost:8090/api/v2/app/version > /dev/null 2>&1 || exit 1"]
interval: 5s
timeout: 3s
retries: 60
start_period: 30s
transmission:
image: lscr.io/linuxserver/transmission:4.0.6
network_mode: host
environment:
PUID: "1000"
PGID: "1000"
TZ: "UTC"
USER: "transmission"
PASS: "transmission"
PEERPORT: "51413"
volumes:
- ./test-data/transmission-config:/config
- ./test-data/downloads/transmission:/downloads
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://localhost:9091/transmission/rpc > /dev/null 2>&1 ; [ $$? -eq 8 ] || [ $$? -eq 0 ]"]
interval: 5s
timeout: 3s
retries: 60
start_period: 30s
deluge:
image: lscr.io/linuxserver/deluge:2.1.1
network_mode: host
environment:
PUID: "1000"
PGID: "1000"
TZ: "UTC"
DELUGE_LOGLEVEL: "info"
volumes:
- ./test-data/deluge-config:/config
- ./test-data/downloads/deluge:/downloads
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://localhost:8112 > /dev/null 2>&1 || exit 1"]
interval: 5s
timeout: 3s
retries: 60
start_period: 30s
utorrent:
image: ekho/utorrent:latest
platform: linux/amd64
ports:
- "8083:8080"
environment:
UID: "1000"
GID: "1000"
dir_root: "/downloads"
dir_active: "/downloads"
dir_completed: "/downloads"
dir_download: "/downloads"
volumes:
- ./test-data/utorrent-config:/data
- ./test-data/downloads/utorrent:/downloads
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://localhost:8080/gui/ > /dev/null 2>&1 || exit 1"]
interval: 5s
timeout: 3s
retries: 60
start_period: 30s
rutorrent:
image: lscr.io/linuxserver/rutorrent:latest
ports:
- "8088:80"
environment:
PUID: "1000"
PGID: "1000"
TZ: "UTC"
volumes:
- ./test-data/rutorrent-config:/config
- ./test-data/downloads/rtorrent:/downloads
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://localhost > /dev/null 2>&1 || exit 1"]
interval: 5s
timeout: 3s
retries: 60
start_period: 60s

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
#
# Prepare the e2e/test-data tree before `docker compose up`.
#
# Re-creates the qBittorrent config from scratch on every run
#
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEST_DATA="$HERE/test-data"
mkdir -p \
"$TEST_DATA/downloads/qbittorrent" \
"$TEST_DATA/downloads/transmission" \
"$TEST_DATA/downloads/deluge" \
"$TEST_DATA/downloads/utorrent" \
"$TEST_DATA/downloads/rtorrent" \
"$TEST_DATA/qbittorrent-config/qBittorrent" \
"$TEST_DATA/transmission-config" \
"$TEST_DATA/deluge-config" \
"$TEST_DATA/utorrent-config" \
"$TEST_DATA/rutorrent-config"
chmod -R a+rwX "$TEST_DATA" 2>/dev/null || true
# qBittorrent credentials: admin / adminadmin
cat > "$TEST_DATA/qbittorrent-config/qBittorrent/qBittorrent.conf" <<'EOF'
[LegalNotice]
Accepted=true
[Preferences]
WebUI\Port=8090
WebUI\Address=*
WebUI\CSRFProtection=false
WebUI\HostHeaderValidation=false
WebUI\LocalHostAuth=false
WebUI\AuthSubnetWhitelistEnabled=true
WebUI\AuthSubnetWhitelist=127.0.0.0/8, ::1/128
WebUI\Username=admin
WebUI\Password_PBKDF2="@ByteArray(ARQ77eY1NUZ366igo9pHIQ==:Bn3qWLqOY3qE6Z+sCx2NoO5q4nhgxhUL3eRD4Zw3+5p9C7+RmrI20bzAjcwHKqcWa+5z6QBQGckCB8sFCnVTGw==)"
Downloads\SavePath=/downloads
EOF
echo "test-data ready under $TEST_DATA"

View File

@@ -0,0 +1,221 @@
import { test, expect } from '@playwright/test';
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import {
loginAndGetToken,
createDownloadClient,
listDownloadClients,
deleteDownloadClient,
updateDownloadCleanerConfig,
getDownloadCleanerConfig,
updateOrphanedFilesConfig,
triggerJob,
} from './helpers/app-api';
import { ALL_CLIENTS, TorrentClientFixture } from './helpers/torrent-clients';
import { buildFolderTorrent, chmodIgnoringEPERM, resetDirectory } from './helpers/torrent-fixtures';
async function waitForTorrents(
driver: { listTorrents(): Promise<Array<{ hash: string }>> },
expectedHashes: string[],
timeoutMs = 15_000,
): Promise<void> {
const want = new Set(expectedHashes.map((h) => h.toLowerCase()));
const start = Date.now();
let last: Set<string> = new Set();
while (Date.now() - start < timeoutMs) {
const list = await driver.listTorrents();
last = new Set(list.map((t) => t.hash.toLowerCase()));
if ([...want].every((h) => last.has(h))) return;
await new Promise((r) => setTimeout(r, 500));
}
const missing = [...want].filter((h) => !last.has(h));
throw new Error(`Torrents missing after ${timeoutMs}ms: ${missing.join(', ')} (saw [${[...last].join(', ')}])`);
}
async function waitForOrphanMove(dir: string, expectedName: string, timeoutMs = 45_000): Promise<string> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (existsSync(dir)) {
const entries = readdirSync(dir);
const moved = entries.find((e) => e === expectedName || e.startsWith(`${expectedName}_`));
if (moved) return moved;
}
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error(`Timed out waiting for orphan "${expectedName}" to appear under ${dir}`);
}
/**
* Orphaned files cleanup e2e — exercises the full pipeline for every
* supported download client:
*
* 1. configure the download cleaner globally (enabled, generous schedule)
* 2. configure the orphaned files cleanup globally (no min age, no purge)
* 3. spin up the client and pre-create two torrents whose data lives in
* /e2e-downloads/<client>/
* 4. delete one of those torrents through the client's API while keeping
* data on disk → produces a real orphan
* 5. trigger the DownloadCleaner job
* 6. assert the surviving torrent's files are untouched and the orphan's
* files were moved into /e2e-downloads/<client>/orphaned/
*
* The downloads volume is bind-mounted at the same path inside every
* container (`/e2e-downloads`) and on the host (`e2e/test-data/downloads`)
* so the spec can assert directly against host paths without any
* DownloadDirectorySource/Target remapping.
*/
const HOST_DOWNLOADS = resolve(__dirname, '..', 'test-data', 'downloads');
const CLIENT_DOWNLOADS = '/downloads';
const APP_DOWNLOADS = '/e2e-downloads';
function clientDirs(slug: string) {
return {
hostScanDir: join(HOST_DOWNLOADS, slug),
hostOrphanedDir: join(HOST_DOWNLOADS, slug, 'orphaned'),
clientSavePath: CLIENT_DOWNLOADS,
appScanDir: `${APP_DOWNLOADS}/${slug}`,
appOrphanedDir: `${APP_DOWNLOADS}/${slug}/orphaned`,
};
}
const SLUG_BY_TYPE: Record<string, string> = {
qBittorrent: 'qbittorrent',
Transmission: 'transmission',
Deluge: 'deluge',
uTorrent: 'utorrent',
rTorrent: 'rtorrent',
};
test.describe.serial('Orphaned files cleanup', () => {
let token: string;
test.beforeAll(async () => {
token = await loginAndGetToken();
// Reset all existing download clients so the spec starts from a clean slate.
const existing = await listDownloadClients(token);
for (const client of existing) {
await deleteDownloadClient(token, client.id);
}
// Enable the global download cleaner + the global orphaned-files config.
// Schedule is irrelevant since we trigger the job manually.
const dcCurrent = await (await getDownloadCleanerConfig(token)).json();
await updateDownloadCleanerConfig(token, {
enabled: true,
cronExpression: dcCurrent.cronExpression || '0 0 * * * ?',
useAdvancedScheduling: dcCurrent.useAdvancedScheduling ?? false,
ignoredDownloads: [],
});
mkdirSync(HOST_DOWNLOADS, { recursive: true });
});
for (const fixture of ALL_CLIENTS) {
runClientScenario(fixture, () => token);
}
});
function runClientScenario(fixture: TorrentClientFixture, getToken: () => string) {
const { driver } = fixture;
const slug = SLUG_BY_TYPE[driver.typeName];
const describeFn = fixture.enabled ? test.describe : test.describe.skip;
describeFn(`${driver.typeName}`, () => {
let keep: { name: string; infoHash: string };
let orphan: { name: string; infoHash: string };
let clientId: string;
const dirs = clientDirs(slug);
test('configures client and produces an orphan', async () => {
test.setTimeout(180_000);
// Fresh per-client scan dir so a previous failed run doesn't bleed in.
resetDirectory(dirs.hostScanDir);
mkdirSync(dirs.hostOrphanedDir, { recursive: true });
chmodIgnoringEPERM(dirs.hostOrphanedDir, 0o777);
const keepName = `keep-${slug}`;
const orphanName = `orphan-${slug}`;
const keepFx = buildFolderTorrent(dirs.hostScanDir, keepName);
const orphanFx = buildFolderTorrent(dirs.hostScanDir, orphanName);
keep = { name: keepName, infoHash: keepFx.infoHash };
orphan = { name: orphanName, infoHash: orphanFx.infoHash };
// Wait for the client's HTTP surface to come up. This is the slowest
// step on a cold compose start.
await driver.ready();
// Wipe any torrents left over from a prior `make test` run — the
// client's session is in a persistent config volume that survives
// `make test` and would otherwise reject re-adding the same infohash.
await driver.clearAllTorrents();
const createRes = await createDownloadClient(getToken(), {
enabled: true,
name: `${driver.typeName} e2e`,
typeName: driver.typeName,
type: 'Torrent',
host: driver.cleanuparrHost,
username: driver.username ?? '',
password: driver.password ?? '',
downloadDirectorySource: dirs.clientSavePath,
downloadDirectoryTarget: dirs.appScanDir,
});
expect(createRes.status).toBeGreaterThanOrEqual(200);
expect(createRes.status).toBeLessThan(300);
const createdClient = await createRes.json();
clientId = createdClient.id;
const ofcRes = await updateOrphanedFilesConfig(getToken(), clientId, {
enabled: true,
scanDirectories: [dirs.appScanDir],
orphanedDirectory: dirs.appOrphanedDir,
minFileAgeHours: 0,
});
expect(ofcRes.status).toBe(200);
await driver.addTorrent({
metainfo: keepFx.metainfo,
savePath: dirs.clientSavePath,
name: keepName,
infoHash: keepFx.infoHash,
});
await driver.addTorrent({
metainfo: orphanFx.metainfo,
savePath: dirs.clientSavePath,
name: orphanName,
infoHash: orphanFx.infoHash,
});
// Some clients process `add` asynchronously — poll for both torrents
// to become visible before continuing.
await waitForTorrents(driver, [keep.infoHash, orphan.infoHash]);
// Delete the orphan torrent from the client while preserving data.
await driver.deleteTorrent(orphan.infoHash);
// Verify orphan is gone from the client but still present on disk.
const afterList = await driver.listTorrents();
const afterHashes = new Set(afterList.map((t) => t.hash.toLowerCase()));
expect(afterHashes.has(keep.infoHash.toLowerCase())).toBe(true);
expect(afterHashes.has(orphan.infoHash.toLowerCase())).toBe(false);
expect(existsSync(join(dirs.hostScanDir, orphanName))).toBe(true);
// Trigger the cleaner. The job runs async on a worker thread; we poll
// the filesystem for the expected outcome rather than sleeping.
const trig = await triggerJob(getToken(), 'DownloadCleaner');
expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true);
const moved = await waitForOrphanMove(dirs.hostOrphanedDir, orphanName);
// Assert: kept torrent's folder survives in place.
expect(existsSync(join(dirs.hostScanDir, keepName, 'data.bin'))).toBe(true);
// Assert: orphan folder no longer at top of scan dir.
expect(existsSync(join(dirs.hostScanDir, orphanName))).toBe(false);
// Assert: orphan folder is under the orphanedDirectory, with its data intact.
expect(existsSync(join(dirs.hostOrphanedDir, moved, 'data.bin'))).toBe(true);
});
});
}

View File

@@ -0,0 +1,256 @@
import { test, expect } from '@playwright/test';
import { existsSync, mkdirSync, readdirSync, statSync, utimesSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import {
loginAndGetToken,
createDownloadClient,
listDownloadClients,
deleteDownloadClient,
updateDownloadCleanerConfig,
getDownloadCleanerConfig,
updateOrphanedFilesConfig,
getGeneralConfig,
updateGeneralConfig,
triggerJob,
OrphanedFilesConfigRequest,
} from './helpers/app-api';
import { QBittorrentDriver } from './helpers/torrent-clients/qbittorrent';
import { chmodIgnoringEPERM, resetDirectory } from './helpers/torrent-fixtures';
/**
* Behavior-level coverage for the orphaned files cleaner that isn't
* client-specific. The per-client integration matrix lives in
* `16-orphaned-files-cleanup.spec.ts`; this file picks qBittorrent as the
* single backing client and exercises configuration knobs:
*
* - PurgeAfterHours (deletes aged, leaves recent, null = never purge)
* - MinFileAgeHours (skips fresh entries)
* - ExcludePatterns
* - Per-client config disabled = no-op
* - DryRun = read-only
*
* "Aged" is simulated by backdating mtime via `utimesSync` after the file
* exists. This is reliable for the purge path (which only consults
* `GetLastWriteTimeUtc`) but not for the move path's MinFileAgeHours check,
* which compares against `MAX(lastWrite, created)` — Linux birthtime
* cannot be portably backdated. That scenario is covered by unit tests.
*/
const HOST_DOWNLOADS = resolve(__dirname, '..', 'test-data', 'downloads');
const APP_DOWNLOADS = '/e2e-downloads';
const SLUG = 'qbittorrent-behaviors';
const HOST_SCAN_DIR = join(HOST_DOWNLOADS, SLUG);
const HOST_ORPHANED_DIR = join(HOST_DOWNLOADS, SLUG, 'orphaned');
const APP_SCAN_DIR = `${APP_DOWNLOADS}/${SLUG}`;
const APP_ORPHANED_DIR = `${APP_DOWNLOADS}/${SLUG}/orphaned`;
function backdateRecursive(path: string, hoursAgo: number): void {
const t = (Date.now() - hoursAgo * 3600_000) / 1000;
const visit = (p: string) => {
utimesSync(p, t, t);
if (statSync(p).isDirectory()) {
for (const e of readdirSync(p)) visit(join(p, e));
}
};
visit(path);
}
function writeOrphanFile(dir: string, name: string, content = 'orphan'): string {
mkdirSync(dir, { recursive: true });
chmodIgnoringEPERM(dir, 0o777);
const path = join(dir, name);
writeFileSync(path, content);
return path;
}
async function waitForCondition(
predicate: () => boolean | Promise<boolean>,
timeoutMs: number,
label: string,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (await predicate()) {
return;
}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`Timed out after ${timeoutMs}ms waiting for: ${label}`);
}
async function triggerAndSettle(token: string): Promise<void> {
const res = await triggerJob(token, 'DownloadCleaner');
expect(res.ok, `triggerJob: ${res.status}`).toBe(true);
// The cleaner is async on a worker thread. Give it time to walk the dirs.
// No seeding downloads means no 10s arr-settle delay — a couple of seconds
// is plenty in practice, but we still poll where it matters.
await new Promise((r) => setTimeout(r, 3000));
}
test.describe.serial('Orphaned files cleanup — behaviors', () => {
const driver = new QBittorrentDriver();
let token: string;
let clientId: string;
test.beforeAll(async () => {
token = await loginAndGetToken();
// Clean slate: remove any leftover clients from other specs.
const existing = await listDownloadClients(token);
for (const client of existing) {
await deleteDownloadClient(token, client.id);
}
// Enable the global download cleaner. Schedule is irrelevant — we
// trigger the job manually.
const dcCurrent = await (await getDownloadCleanerConfig(token)).json();
await updateDownloadCleanerConfig(token, {
enabled: true,
cronExpression: dcCurrent.cronExpression || '0 0 * * * ?',
useAdvancedScheduling: dcCurrent.useAdvancedScheduling ?? false,
ignoredDownloads: [],
});
mkdirSync(HOST_DOWNLOADS, { recursive: true });
// Bring up qBittorrent and register it with Cleanuparr.
await driver.ready();
await driver.clearAllTorrents();
const createRes = await createDownloadClient(token, {
enabled: true,
name: 'qBittorrent behaviors',
typeName: driver.typeName,
type: 'Torrent',
host: driver.cleanuparrHost,
username: driver.username ?? '',
password: driver.password ?? '',
downloadDirectorySource: '/downloads',
downloadDirectoryTarget: APP_SCAN_DIR,
});
expect(createRes.ok, `createDownloadClient: ${createRes.status}`).toBe(true);
const created = await createRes.json();
clientId = created.id;
});
test.beforeEach(async () => {
// Reset filesystem state before each scenario.
resetDirectory(HOST_SCAN_DIR);
mkdirSync(HOST_ORPHANED_DIR, { recursive: true });
chmodIgnoringEPERM(HOST_ORPHANED_DIR, 0o777);
// No torrents in the client → claimedPaths is empty → every entry in
// scan dir is treated as orphan.
await driver.clearAllTorrents();
});
const configureOrphanedFiles = async (
overrides: Partial<OrphanedFilesConfigRequest> = {},
): Promise<void> => {
const config: OrphanedFilesConfigRequest = {
enabled: true,
scanDirectories: [APP_SCAN_DIR],
orphanedDirectory: APP_ORPHANED_DIR,
excludePatterns: [],
minFileAgeHours: 0,
purgeAfterHours: null,
...overrides,
};
const res = await updateOrphanedFilesConfig(token, clientId, config);
expect(res.ok, `updateOrphanedFilesConfig: ${res.status}`).toBe(true);
};
test('PurgeAfterHours deletes aged entries from the orphaned directory', async () => {
test.setTimeout(60_000);
const aged = writeOrphanFile(HOST_ORPHANED_DIR, 'aged.bin');
backdateRecursive(aged, 25);
await configureOrphanedFiles({ purgeAfterHours: 24 });
await triggerAndSettle(token);
await waitForCondition(() => !existsSync(aged), 10_000, `purge of ${aged}`);
});
test('PurgeAfterHours leaves entries newer than the cutoff', async () => {
test.setTimeout(60_000);
const fresh = writeOrphanFile(HOST_ORPHANED_DIR, 'fresh.bin');
await configureOrphanedFiles({ purgeAfterHours: 24 });
await triggerAndSettle(token);
expect(existsSync(fresh)).toBe(true);
});
test('PurgeAfterHours null never purges, even very old entries', async () => {
test.setTimeout(60_000);
const ancient = writeOrphanFile(HOST_ORPHANED_DIR, 'ancient.bin');
backdateRecursive(ancient, 24 * 365);
await configureOrphanedFiles({ purgeAfterHours: null });
await triggerAndSettle(token);
expect(existsSync(ancient)).toBe(true);
});
test('MinFileAgeHours skips fresh entries in the scan directory', async () => {
test.setTimeout(60_000);
const fresh = writeOrphanFile(HOST_SCAN_DIR, 'too-fresh.bin');
await configureOrphanedFiles({ minFileAgeHours: 1 });
await triggerAndSettle(token);
// Still in the scan dir, not moved to orphaned dir.
expect(existsSync(fresh)).toBe(true);
expect(existsSync(join(HOST_ORPHANED_DIR, 'too-fresh.bin'))).toBe(false);
});
test('ExcludePatterns prevents matching entries from being moved', async () => {
test.setTimeout(60_000);
const excluded = writeOrphanFile(HOST_SCAN_DIR, 'metadata.nfo');
const matched = writeOrphanFile(HOST_SCAN_DIR, 'real-orphan.bin');
await configureOrphanedFiles({ excludePatterns: ['*.nfo'] });
await triggerAndSettle(token);
await waitForCondition(
() => !existsSync(matched),
10_000,
'real-orphan.bin to be moved',
);
// .nfo file untouched.
expect(existsSync(excluded)).toBe(true);
});
test('Disabled per-client config is a no-op', async () => {
test.setTimeout(60_000);
const orphan = writeOrphanFile(HOST_SCAN_DIR, 'leave-me.bin');
await configureOrphanedFiles({ enabled: false });
await triggerAndSettle(token);
expect(existsSync(orphan)).toBe(true);
expect(existsSync(join(HOST_ORPHANED_DIR, 'leave-me.bin'))).toBe(false);
});
test.describe('DryRun', () => {
test.afterEach(async () => {
// Always clear dry-run so it doesn't leak into subsequent specs.
const current = await getGeneralConfig(token);
await updateGeneralConfig(token, { ...current, dryRun: false });
});
test('DryRun skips filesystem mutations', async () => {
test.setTimeout(60_000);
const orphan = writeOrphanFile(HOST_SCAN_DIR, 'pretend-only.bin');
const current = await getGeneralConfig(token);
await updateGeneralConfig(token, { ...current, dryRun: true });
await configureOrphanedFiles();
await triggerAndSettle(token);
expect(existsSync(orphan)).toBe(true);
expect(existsSync(join(HOST_ORPHANED_DIR, 'pretend-only.bin'))).toBe(false);
});
});
});

View File

@@ -304,6 +304,55 @@ export async function deleteDownloadClient(accessToken: string, clientId: string
});
}
export async function listDownloadClients(accessToken: string): Promise<Array<{ id: string; name: string; typeName: string }>> {
const res = await fetch(`${API}/api/configuration/download_client`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) {
throw new Error(`Failed to list download clients: ${res.status}`);
}
const body = await res.json();
return body.clients ?? [];
}
// --- Orphaned files cleanup helpers ---
export interface OrphanedFilesConfigRequest {
enabled: boolean;
scanDirectories: string[];
orphanedDirectory: string;
excludePatterns?: string[];
minFileAgeHours?: number;
purgeAfterHours?: number | null;
}
export async function updateOrphanedFilesConfig(
accessToken: string,
downloadClientId: string,
config: OrphanedFilesConfigRequest,
): Promise<Response> {
return fetch(`${API}/api/orphaned-files-config/${downloadClientId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(config),
});
}
// --- Job triggering ---
export async function triggerJob(
accessToken: string,
jobType: 'QueueCleaner' | 'MalwareBlocker' | 'DownloadCleaner' | 'BlacklistSynchronizer' | 'CustomFormatScoreSyncer',
): Promise<Response> {
return fetch(`${API}/api/jobs/${jobType}/trigger`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
});
}
// --- General config / auth-bypass helpers ---
export async function getGeneralConfig(accessToken: string): Promise<Record<string, unknown>> {

View File

@@ -0,0 +1,112 @@
import { TorrentClientDriver, pollUntilOk } from './types';
/**
* Deluge driver (Web UI JSON-RPC at /json).
*
* Auth flow:
* - POST { method: 'auth.login', params: [password] } — sets session cookie
* - POST { method: 'web.connected' } — true once Web UI is connected to a daemon
* - POST { method: 'web.connect', params: [host_id] } — pick the first
* daemon if Web UI isn't connected yet
*
* Default linuxserver/deluge web password is `deluge`.
*/
export class DelugeDriver implements TorrentClientDriver {
readonly typeName = 'Deluge' as const;
readonly cleanuparrHost: string;
readonly username = '';
readonly password: string;
private readonly directJson: string;
private cookie: string | null = null;
private requestId = 1;
constructor(host = 'http://localhost:8112', password = 'deluge') {
this.cleanuparrHost = host;
this.password = password;
this.directJson = `${host.replace(/\/$/, '')}/json`;
}
async ready(): Promise<void> {
await pollUntilOk(
async () => {
const res = await fetch(this.directJson, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: 'auth.login', params: [this.password], id: this.requestId++ }),
});
if (!res.ok) return false;
const setCookie = res.headers.get('set-cookie');
if (setCookie) this.cookie = setCookie.split(';')[0];
const body = await res.json();
return body.result === true;
},
{ label: 'Deluge Web UI' },
);
// Ensure Web UI is bound to the local daemon. On a fresh install the
// Web UI starts unconnected and `core.*` calls fail until we connect.
const connected = await this.call<boolean>('web.connected', []);
if (!connected) {
const hosts = await this.call<Array<Array<string>>>('web.get_hosts', []);
const firstHost = hosts?.[0]?.[0];
if (!firstHost) {
throw new Error('Deluge Web UI has no daemon to connect to (web.get_hosts returned empty)');
}
await this.call('web.connect', [firstHost]);
const connectedAfter = await this.call<boolean>('web.connected', []);
if (!connectedAfter) {
throw new Error('Deluge Web UI is not connected to a daemon after web.connect');
}
}
}
private async call<T>(method: string, params: unknown[]): Promise<T> {
const res = await fetch(this.directJson, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.cookie ? { Cookie: this.cookie } : {}),
},
body: JSON.stringify({ method, params, id: this.requestId++ }),
});
if (!res.ok) {
throw new Error(`Deluge ${method} failed: ${res.status} ${await res.text()}`);
}
const body = await res.json();
if (body.error) {
throw new Error(`Deluge ${method} error: ${JSON.stringify(body.error)}`);
}
return body.result as T;
}
async addTorrent({ metainfo, savePath, name }: { metainfo: Buffer; savePath: string; name: string; infoHash: string }): Promise<void> {
const filename = `${name}.torrent`;
const b64 = metainfo.toString('base64');
await this.call('core.add_torrent_file', [
filename,
b64,
{
download_location: savePath,
add_paused: true,
seed_mode: true, // skip hash check — treat as already complete
},
]);
}
async deleteTorrent(infoHash: string): Promise<void> {
// remove_torrent signature: (torrent_id, remove_data: bool)
await this.call('core.remove_torrent', [infoHash, false]);
}
async clearAllTorrents(): Promise<void> {
const all = await this.listTorrents();
for (const t of all) {
await this.call('core.remove_torrent', [t.hash, false]);
}
}
async listTorrents(): Promise<Array<{ hash: string; name: string }>> {
const result = await this.call<Record<string, { name: string }>>('core.get_torrents_status', [{}, ['name']]);
return Object.entries(result ?? {}).map(([hash, info]) => ({ hash, name: info.name }));
}
}

View File

@@ -0,0 +1,25 @@
import { QBittorrentDriver } from './qbittorrent';
import { TransmissionDriver } from './transmission';
import { DelugeDriver } from './deluge';
import { RTorrentDriver } from './rtorrent';
import { UTorrentDriver } from './utorrent';
import { TorrentClientDriver, TorrentClientType } from './types';
export { TorrentClientDriver, TorrentClientType };
export { ClientNotImplementedError } from './types';
export interface TorrentClientFixture {
driver: TorrentClientDriver;
/** Whether the spec should actually run against this driver. */
enabled: boolean;
/** Reason this client is disabled (shown in test.skip). */
skipReason?: string;
}
export const ALL_CLIENTS: TorrentClientFixture[] = [
{ driver: new QBittorrentDriver(), enabled: true },
{ driver: new TransmissionDriver(), enabled: true },
{ driver: new DelugeDriver(), enabled: true },
{ driver: new UTorrentDriver(), enabled: true },
{ driver: new RTorrentDriver(), enabled: true },
];

View File

@@ -0,0 +1,124 @@
import { TorrentClientDriver, pollUntilOk } from './types';
/**
* qBittorrent driver (WebUI v2).
*
* Auth note: relies on the linuxserver/qbittorrent default of bypassing auth
* for localhost. Combined with `network_mode: host`, requests from the test
* runner originate from 127.0.0.1, so login is skipped. If running against
* a qBittorrent without localhost-bypass, set `username` and `password` and
* the driver will POST /api/v2/auth/login.
*/
export class QBittorrentDriver implements TorrentClientDriver {
readonly typeName = 'qBittorrent' as const;
readonly cleanuparrHost: string;
readonly username?: string;
readonly password?: string;
private readonly directHost: string;
private cookie: string | null = null;
constructor(host = 'http://localhost:8090', username = 'admin', password = 'adminadmin') {
this.cleanuparrHost = host;
this.directHost = host;
this.username = username;
this.password = password;
}
async ready(): Promise<void> {
await pollUntilOk(
async () => {
const res = await fetch(`${this.directHost}/api/v2/app/version`, {
headers: this.cookie ? { Cookie: this.cookie } : undefined,
});
return res.ok || res.status === 403;
},
{ label: 'qBittorrent WebUI' },
);
if (this.username && this.password) {
await this.login();
}
}
private async login(): Promise<void> {
const body = new URLSearchParams({ username: this.username!, password: this.password! });
const res = await fetch(`${this.directHost}/api/v2/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
// qBittorrent returns HTTP 200 with body "Ok." on success and "Fails." on
// bad credentials, so we cannot rely on res.ok alone.
const responseBody = (await res.text()).trim();
if (!res.ok || responseBody !== 'Ok.') {
throw new Error(`qBittorrent login failed: ${res.status} ${responseBody}`);
}
const cookie = res.headers.get('set-cookie');
if (cookie) {
// Strip flags — Node's fetch returns the full header
this.cookie = cookie.split(';')[0];
}
}
async addTorrent({ metainfo, savePath }: { metainfo: Buffer; savePath: string; name: string; infoHash: string }): Promise<void> {
const form = new FormData();
form.append('torrents', new Blob([new Uint8Array(metainfo)]), 'torrent.torrent');
form.append('savepath', savePath);
form.append('paused', 'true');
form.append('skip_checking', 'true');
form.append('autoTMM', 'false');
const res = await fetch(`${this.directHost}/api/v2/torrents/add`, {
method: 'POST',
headers: this.cookie ? { Cookie: this.cookie } : undefined,
body: form,
});
if (!res.ok) {
throw new Error(`qBittorrent add failed: ${res.status} ${await res.text()}`);
}
}
async deleteTorrent(infoHash: string): Promise<void> {
const body = new URLSearchParams({ hashes: infoHash, deleteFiles: 'false' });
const res = await fetch(`${this.directHost}/api/v2/torrents/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(this.cookie ? { Cookie: this.cookie } : {}),
},
body: body.toString(),
});
if (!res.ok) {
throw new Error(`qBittorrent delete failed: ${res.status} ${await res.text()}`);
}
}
async clearAllTorrents(): Promise<void> {
const all = await this.listTorrents();
if (all.length === 0) return;
const body = new URLSearchParams({
hashes: all.map((t) => t.hash).join('|'),
deleteFiles: 'false',
});
const res = await fetch(`${this.directHost}/api/v2/torrents/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(this.cookie ? { Cookie: this.cookie } : {}),
},
body: body.toString(),
});
if (!res.ok) {
throw new Error(`qBittorrent clear failed: ${res.status}`);
}
}
async listTorrents(): Promise<Array<{ hash: string; name: string }>> {
const res = await fetch(`${this.directHost}/api/v2/torrents/info`, {
headers: this.cookie ? { Cookie: this.cookie } : undefined,
});
if (!res.ok) {
throw new Error(`qBittorrent list failed: ${res.status}`);
}
const items: Array<{ hash: string; name: string }> = await res.json();
return items.map((t) => ({ hash: t.hash, name: t.name }));
}
}

View File

@@ -0,0 +1,190 @@
import { TorrentClientDriver, pollUntilOk } from './types';
/**
* rTorrent driver via XML-RPC over HTTP. linuxserver/rutorrent exposes
* SCGI-backed XML-RPC at `/RPC2` on the rutorrent web port (8088 by default).
*
* rTorrent is the most awkward of the supported clients to drive from a
* test runner because:
* - It uses XML-RPC (not JSON), so we hand-build the request/response
* - There is no native "skip hash check" — we use `load.raw` (load only,
* no auto-start) so rTorrent never tries to peer or verify pieces.
*
* If the spec for this client fails because of XML escaping or because the
* rutorrent nginx isn't routing /RPC2, this file is the most likely place
* to need adjustment.
*/
export class RTorrentDriver implements TorrentClientDriver {
readonly typeName = 'rTorrent' as const;
readonly cleanuparrHost: string;
readonly username?: string;
readonly password?: string;
private readonly directRpc: string;
constructor(host = 'http://localhost:8088/RPC2') {
this.cleanuparrHost = host;
this.directRpc = host;
}
async ready(): Promise<void> {
await pollUntilOk(
async () => {
try {
await this.call('system.client_version', []);
return true;
} catch {
return false;
}
},
{ label: 'rTorrent XML-RPC' },
);
}
private async call(method: string, params: Array<XmlRpcValue>): Promise<any> {
const xml = buildXmlRpcRequest(method, params);
const res = await fetch(this.directRpc, {
method: 'POST',
headers: { 'Content-Type': 'text/xml' },
body: xml,
});
if (!res.ok) {
throw new Error(`rTorrent ${method} failed: ${res.status} ${await res.text()}`);
}
const text = await res.text();
return parseXmlRpcResponse(text);
}
async addTorrent({ metainfo, savePath, name, infoHash }: { metainfo: Buffer; savePath: string; name: string; infoHash: string }): Promise<void> {
// load.raw_start_verbose loads AND starts the torrent. Starting triggers
// an immediate hash check, which (for our tiny 32KB files matching the
// metainfo piece hashes) populates `d.base_path` — the field Cleanuparr
// reads as the torrent's save path. Without starting, `d.base_path`
// stays empty and Cleanuparr can't build a claimed-paths set.
await this.call('load.raw_start_verbose', [
'',
{ type: 'base64', value: metainfo.toString('base64') },
`d.directory.set="${savePath}"`,
`d.custom1.set="${name}"`,
]);
void infoHash;
}
async deleteTorrent(infoHash: string): Promise<void> {
// d.erase removes the torrent from rTorrent's session without touching
// the data on disk.
await this.call('d.erase', [infoHash.toUpperCase()]);
}
async clearAllTorrents(): Promise<void> {
const all = await this.listTorrents();
for (const t of all) {
try {
await this.call('d.erase', [t.hash.toUpperCase()]);
} catch {
// best-effort: continue clearing
}
}
}
async listTorrents(): Promise<Array<{ hash: string; name: string }>> {
const result = await this.call('d.multicall2', ['', 'main', 'd.hash=', 'd.name=']);
if (!Array.isArray(result)) return [];
return result.map((row: unknown) => {
const arr = row as unknown[];
return { hash: String(arr[0]).toLowerCase(), name: String(arr[1]) };
});
}
}
type XmlRpcValue = string | number | boolean | { type: 'base64'; value: string };
function buildXmlRpcRequest(method: string, params: XmlRpcValue[]): string {
const paramsXml = params.map((p) => `<param>${encodeValue(p)}</param>`).join('');
return `<?xml version="1.0"?><methodCall><methodName>${escapeXml(method)}</methodName><params>${paramsXml}</params></methodCall>`;
}
function encodeValue(v: XmlRpcValue): string {
if (typeof v === 'number') {
return Number.isInteger(v) ? `<value><int>${v}</int></value>` : `<value><double>${v}</double></value>`;
}
if (typeof v === 'boolean') {
return `<value><boolean>${v ? 1 : 0}</boolean></value>`;
}
if (typeof v === 'string') {
return `<value><string>${escapeXml(v)}</string></value>`;
}
if (v && typeof v === 'object' && v.type === 'base64') {
return `<value><base64>${v.value}</base64></value>`;
}
throw new Error(`xml-rpc: unsupported value ${typeof v}`);
}
function escapeXml(s: string): string {
return s.replace(/[<>&'"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' }[c]!));
}
function parseXmlRpcResponse(xml: string): unknown {
if (xml.includes('<fault>')) {
const msg = xml.match(/<name>faultString<\/name>\s*<value><string>([^<]+)<\/string>/)?.[1] ?? xml;
throw new Error(`xml-rpc fault: ${msg}`);
}
const paramsMatch = xml.match(/<params>([\s\S]*?)<\/params>/);
if (!paramsMatch) return null;
return parseValue(paramsMatch[1]);
}
function parseValue(xml: string): unknown {
// Very small subset of XML-RPC parsing: handles int/string/boolean/array/struct/base64.
const tag = xml.match(/<value>\s*<([a-zA-Z0-9]+)>/);
if (!tag) {
// Bare <value>text</value> is treated as string per spec.
const bare = xml.match(/<value>([\s\S]*?)<\/value>/);
return bare ? bare[1].trim() : null;
}
const type = tag[1];
if (type === 'array') {
// Greedy — for nested arrays, we want the OUTER </data> not the first inner one.
const inner = xml.match(/<array>\s*<data>([\s\S]*)<\/data>\s*<\/array>/)?.[1] ?? '';
return splitValues(inner).map(parseValue);
}
if (type === 'struct') {
const inner = xml.match(/<struct>([\s\S]*)<\/struct>/)?.[1] ?? '';
const out: Record<string, unknown> = {};
const memberRe = /<member>\s*<name>([^<]+)<\/name>\s*([\s\S]*?)<\/member>/g;
let m: RegExpExecArray | null;
while ((m = memberRe.exec(inner)) !== null) {
out[m[1]] = parseValue(m[2]);
}
return out;
}
const scalar = xml.match(new RegExp(`<${type}>([\\s\\S]*?)<\\/${type}>`))?.[1] ?? '';
if (type === 'int' || type === 'i4') return Number(scalar);
if (type === 'boolean') return scalar === '1';
if (type === 'double') return Number(scalar);
return decodeXml(scalar);
}
function splitValues(xml: string): string[] {
const out: string[] = [];
let depth = 0;
let start = -1;
for (let i = 0; i < xml.length; i++) {
if (xml.startsWith('<value>', i)) {
if (depth === 0) start = i;
depth++;
i += '<value>'.length - 1;
} else if (xml.startsWith('</value>', i)) {
depth--;
if (depth === 0 && start !== -1) {
out.push(xml.slice(start, i + '</value>'.length));
start = -1;
}
i += '</value>'.length - 1;
}
}
return out;
}
function decodeXml(s: string): string {
return s.replace(/&(lt|gt|amp|apos|quot);/g, (_, e) => ({ lt: '<', gt: '>', amp: '&', apos: "'", quot: '"' }[e as 'lt']!));
}

View File

@@ -0,0 +1,107 @@
import { TorrentClientDriver, pollUntilOk } from './types';
/**
* Transmission driver (transmission-rpc protocol).
*
* Transmission requires a CSRF-style session id obtained by issuing any RPC
* call and reading the `X-Transmission-Session-Id` header from the 409
* response, then replaying with that header. We refresh the id transparently
* on each request.
*
* Compose wires linuxserver/transmission with USER=transmission /
* PASS=transmission, which gates the RPC endpoint behind basic auth.
*/
export class TransmissionDriver implements TorrentClientDriver {
readonly typeName = 'Transmission' as const;
readonly cleanuparrHost: string;
readonly username: string;
readonly password: string;
private readonly directRpc: string;
private sessionId = '';
constructor(host = 'http://localhost:9091/transmission', username = 'transmission', password = 'transmission') {
this.cleanuparrHost = host;
this.username = username;
this.password = password;
this.directRpc = `${host.replace(/\/$/, '')}/rpc`;
}
async ready(): Promise<void> {
await pollUntilOk(
async () => {
try {
await this.call('session-get', {});
return true;
} catch {
return false;
}
},
{ label: 'Transmission RPC' },
);
}
private authHeader(): string {
return 'Basic ' + Buffer.from(`${this.username}:${this.password}`).toString('base64');
}
private async call(method: string, args: Record<string, unknown>): Promise<any> {
const send = async () => {
return fetch(this.directRpc, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': this.authHeader(),
'X-Transmission-Session-Id': this.sessionId,
},
body: JSON.stringify({ method, arguments: args }),
});
};
let res = await send();
if (res.status === 409) {
this.sessionId = res.headers.get('x-transmission-session-id') ?? '';
res = await send();
}
if (!res.ok) {
throw new Error(`Transmission ${method} failed: ${res.status} ${await res.text()}`);
}
const body = await res.json();
if (body.result !== 'success') {
throw new Error(`Transmission ${method} non-success: ${body.result}`);
}
return body.arguments;
}
async addTorrent({ metainfo, savePath, infoHash }: { metainfo: Buffer; savePath: string; name: string; infoHash: string }): Promise<void> {
await this.call('torrent-add', {
metainfo: metainfo.toString('base64'),
'download-dir': savePath,
paused: true,
});
// The torrent is added in a paused, unverified state. Transmission will
// try to verify on resume — we never resume, so it stays in stopped/
// queued state with savePath populated, which is enough for the cleaner
// to pick it up via GetAllTorrentsLite.
void infoHash;
}
async deleteTorrent(infoHash: string): Promise<void> {
await this.call('torrent-remove', {
ids: [infoHash],
'delete-local-data': false,
});
}
async clearAllTorrents(): Promise<void> {
const all = await this.listTorrents();
if (all.length === 0) return;
await this.call('torrent-remove', {
ids: all.map((t) => t.hash),
'delete-local-data': false,
});
}
async listTorrents(): Promise<Array<{ hash: string; name: string }>> {
const args = await this.call('torrent-get', { fields: ['hashString', 'name'] });
return (args.torrents ?? []).map((t: { hashString: string; name: string }) => ({ hash: t.hashString, name: t.name }));
}
}

View File

@@ -0,0 +1,61 @@
export type TorrentClientType = 'qBittorrent' | 'Transmission' | 'Deluge' | 'uTorrent' | 'rTorrent';
/**
* Minimal driver surface used by the orphaned-files spec. Each implementation
* wraps a specific torrent client's HTTP API and exposes:
*
* - `ready()` — block until the client is accepting requests
* - `addTorrent({ metainfo, savePath, name })` — register a torrent whose
* data already exists on disk (no actual downloading)
* - `deleteTorrent(hash, { deleteFiles })` — remove a torrent from the
* client; the spec always passes deleteFiles=false to leave the orphan
* on disk so the cleaner has something to detect
* - `listTorrents()` — used to assert state after operations
*
* `host` is the URL the *Cleanuparr backend* should be configured with — not
* necessarily the URL the test helper itself talks to (some clients require
* a different sub-path for their RPC endpoint).
*/
export interface TorrentClientDriver {
readonly typeName: TorrentClientType;
/** Hostname+path the Cleanuparr backend uses to reach this client. */
readonly cleanuparrHost: string;
readonly username?: string;
readonly password?: string;
ready(): Promise<void>;
addTorrent(input: { metainfo: Buffer; savePath: string; name: string; infoHash: string }): Promise<void>;
deleteTorrent(infoHash: string): Promise<void>;
listTorrents(): Promise<Array<{ hash: string; name: string }>>;
/**
* Remove every torrent currently registered with the client without deleting
* data on disk. Called at the start of each test to make the spec
* idempotent across re-runs (the torrent client's state persists in its
* config volume between `make test` invocations).
*/
clearAllTorrents(): Promise<void>;
}
export class ClientNotImplementedError extends Error {
constructor(client: TorrentClientType, detail: string) {
super(`${client}: ${detail}`);
this.name = 'ClientNotImplementedError';
}
}
export async function pollUntilOk(
fn: () => Promise<boolean>,
{ timeoutMs = 90_000, intervalMs = 1500, label = 'condition' }: { timeoutMs?: number; intervalMs?: number; label?: string } = {},
): Promise<void> {
const start = Date.now();
let lastError: unknown;
while (Date.now() - start < timeoutMs) {
try {
if (await fn()) return;
} catch (err) {
lastError = err;
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`Timed out waiting for ${label} after ${timeoutMs}ms (last error: ${String(lastError)})`);
}

View File

@@ -0,0 +1,116 @@
import { TorrentClientDriver, pollUntilOk } from './types';
/**
* µTorrent driver (WebUI HTTP API).
*
* The legacy uTorrent Server for Linux is reanimated by the `ekho/utorrent`
* Docker image. Auth is HTTP Basic; the WebUI also requires a CSRF token
* fetched from /gui/token.html plus a `GUID` cookie set by that same call.
*
* The list endpoint returns a JSON object whose `torrents` field is an array
* of arrays — each row is `[hash, status, name, size, ...]`.
*/
export class UTorrentDriver implements TorrentClientDriver {
readonly typeName = 'uTorrent' as const;
readonly cleanuparrHost: string;
readonly username: string;
readonly password: string;
private readonly directHost: string;
private token = '';
private cookie = '';
constructor(host = 'http://localhost:8083', username = 'admin', password = '') {
this.cleanuparrHost = host;
this.directHost = host;
this.username = username;
this.password = password;
}
private authHeader(): string {
return 'Basic ' + Buffer.from(`${this.username}:${this.password}`).toString('base64');
}
private requestHeaders(): Record<string, string> {
const h: Record<string, string> = { Authorization: this.authHeader() };
if (this.cookie) h.Cookie = this.cookie;
return h;
}
async ready(): Promise<void> {
await pollUntilOk(
async () => {
try {
await this.refreshToken();
return this.token !== '';
} catch {
return false;
}
},
{ label: 'uTorrent WebUI' },
);
}
private async refreshToken(): Promise<void> {
const res = await fetch(`${this.directHost}/gui/token.html`, {
headers: { Authorization: this.authHeader() },
});
if (!res.ok) {
throw new Error(`uTorrent token: ${res.status}`);
}
const text = await res.text();
const match = text.match(/<div[^>]*id=['"]token['"][^>]*>([^<]+)<\/div>/);
if (!match) {
throw new Error(`uTorrent token not found in response body: ${text.slice(0, 200)}`);
}
this.token = match[1];
const setCookie = res.headers.get('set-cookie');
if (setCookie) {
this.cookie = setCookie.split(';')[0];
}
}
async addTorrent({ metainfo, name }: { metainfo: Buffer; savePath: string; name: string; infoHash: string }): Promise<void> {
const form = new FormData();
form.append('torrent_file', new Blob([new Uint8Array(metainfo)]), `${name}.torrent`);
const url = `${this.directHost}/gui/?token=${encodeURIComponent(this.token)}&action=add-file`;
const res = await fetch(url, {
method: 'POST',
headers: this.requestHeaders(),
body: form,
});
if (!res.ok) {
throw new Error(`uTorrent add: ${res.status} ${await res.text()}`);
}
}
async deleteTorrent(infoHash: string): Promise<void> {
// `remove` removes the torrent from the client without touching files;
// `removedata` / `removedatatorrent` delete data and torrent file.
const url = `${this.directHost}/gui/?token=${encodeURIComponent(this.token)}&action=remove&hash=${infoHash.toUpperCase()}`;
const res = await fetch(url, { headers: this.requestHeaders() });
if (!res.ok) {
throw new Error(`uTorrent remove: ${res.status}`);
}
}
async listTorrents(): Promise<Array<{ hash: string; name: string }>> {
const url = `${this.directHost}/gui/?token=${encodeURIComponent(this.token)}&list=1`;
const res = await fetch(url, { headers: this.requestHeaders() });
if (!res.ok) {
throw new Error(`uTorrent list: ${res.status}`);
}
const body: { torrents?: unknown[][] } = await res.json();
return (body.torrents ?? []).map((row) => ({
hash: String(row[0]).toLowerCase(),
name: String(row[2]),
}));
}
async clearAllTorrents(): Promise<void> {
const all = await this.listTorrents();
for (const t of all) {
const url = `${this.directHost}/gui/?token=${encodeURIComponent(this.token)}&action=remove&hash=${t.hash.toUpperCase()}`;
await fetch(url, { headers: this.requestHeaders() });
}
}
}

View File

@@ -0,0 +1,148 @@
import { createHash, randomBytes } from 'node:crypto';
import { chmodSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* Bencode an arbitrary value. Supports integers, Buffers, strings (utf-8),
* arrays, and plain objects (whose keys are sorted lexicographically as
* required by BEP-3).
*/
function bencode(value: unknown): Buffer {
if (typeof value === 'number') {
if (!Number.isInteger(value)) {
throw new Error(`bencode: non-integer number ${value}`);
}
return Buffer.from(`i${value}e`);
}
if (Buffer.isBuffer(value)) {
return Buffer.concat([Buffer.from(`${value.length}:`), value]);
}
if (typeof value === 'string') {
const buf = Buffer.from(value, 'utf8');
return Buffer.concat([Buffer.from(`${buf.length}:`), buf]);
}
if (Array.isArray(value)) {
return Buffer.concat([Buffer.from('l'), ...value.map(bencode), Buffer.from('e')]);
}
if (value !== null && typeof value === 'object') {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
const parts: Buffer[] = [Buffer.from('d')];
for (const k of keys) {
parts.push(bencode(k));
parts.push(bencode(obj[k]));
}
parts.push(Buffer.from('e'));
return Buffer.concat(parts);
}
throw new Error(`bencode: unsupported value ${typeof value}`);
}
export interface GeneratedTorrent {
/** Bencoded .torrent metainfo buffer */
metainfo: Buffer;
/** Lowercase hex SHA-1 of the bencoded info dict — the torrent's infohash */
infoHash: string;
/** Name of the top-level directory inside the torrent */
name: string;
/** Absolute on-disk path to the directory containing the torrent's data */
contentPath: string;
}
/**
* Build a single-file multi-piece torrent on disk and return its metainfo.
*
* The data file is written to `<savePath>/<name>/data.bin` and contains
* deterministic random bytes seeded from `name` so re-runs produce the same
* content (and thus the same infohash) for a given name.
*
* @param savePath - directory where the torrent's top-level folder will be created
* @param name - top-level folder name (also the torrent's `info.name`)
* @param sizeBytes - total size of the inner data file
*/
export function buildFolderTorrent(savePath: string, name: string, sizeBytes = 32_768): GeneratedTorrent {
const contentPath = join(savePath, name);
mkdirSync(contentPath, { recursive: true });
chmodIgnoringEPERM(contentPath, 0o777);
// Deterministic content: HMAC-like expansion from the name so two runs
// produce identical bytes (and thus identical pieces / infohash).
const seed = createHash('sha256').update(`cleanuparr-e2e:${name}`).digest();
const data = Buffer.alloc(sizeBytes);
let offset = 0;
let counter = 0;
while (offset < sizeBytes) {
const block = createHash('sha256').update(seed).update(Buffer.from([counter & 0xff, (counter >> 8) & 0xff])).digest();
block.copy(data, offset, 0, Math.min(block.length, sizeBytes - offset));
offset += block.length;
counter++;
}
writeFileSync(join(contentPath, 'data.bin'), data);
const pieceLength = 16384;
const pieces: Buffer[] = [];
for (let i = 0; i < data.length; i += pieceLength) {
const piece = data.subarray(i, Math.min(i + pieceLength, data.length));
pieces.push(createHash('sha1').update(piece).digest());
}
const piecesConcat = Buffer.concat(pieces);
const info = {
name,
'piece length': pieceLength,
pieces: piecesConcat,
files: [
{ length: data.length, path: ['data.bin'] },
],
// Mark as private to short-circuit DHT/PEX work in clients.
private: 1,
};
const metainfo = bencode({
announce: 'http://tracker.invalid/announce',
'created by': 'cleanuparr-e2e',
'creation date': 0,
info,
});
const infoHash = createHash('sha1').update(bencode(info)).digest('hex');
return { metainfo, infoHash, name, contentPath };
}
/**
* `chmodSync` that tolerates EPERM. The torrent-client bind mounts
* (`test-data/downloads/<client>`) are chowned to PUID=1000 by
* linuxserver.io entrypoints, while CI's Playwright runner is uid 1001
* and cannot chmod paths it doesn't own. Mode bits are already 0o777
* from setup-test-data.sh's `chmod -R a+rwX`, so the chmod is best-effort.
*/
export function chmodIgnoringEPERM(path: string, mode: number): void {
try {
chmodSync(path, mode);
} catch (err) {
if ((err as { code?: string }).code !== 'EPERM') {
throw err;
}
}
}
/**
* Wipe and recreate a directory. Used at test setup to reset client data.
*/
export function resetDirectory(path: string): void {
mkdirSync(path, { recursive: true });
for (const entry of readdirSync(path)) {
rmSync(join(path, entry), { recursive: true, force: true });
}
chmodIgnoringEPERM(path, 0o777);
}
/**
* Write a random extra file directly under a directory. Useful to seed an
* unrelated file that the cleaner should classify as orphaned.
*/
export function writeRandomFile(dir: string, name: string, sizeBytes = 1024): string {
mkdirSync(dir, { recursive: true });
const path = join(dir, name);
writeFileSync(path, randomBytes(sizeBytes));
return path;
}