mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-06-10 14:55:34 -04:00
Add orphaned files cleanup (#618)
This commit is contained in:
@@ -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>()
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -47,7 +47,9 @@ public class DownloadCleanerIntegrationTests : IDisposable
|
||||
_fixture.DownloadServiceFactory,
|
||||
_fixture.EventPublisher,
|
||||
_fixture.TimeProvider,
|
||||
_fixture.HardLinkFileService);
|
||||
_fixture.SeedingRulesService,
|
||||
_fixture.UnlinkedService,
|
||||
_fixture.OrphanedFilesService);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -58,6 +58,7 @@ public class DynamicHttpClientProvider : IDynamicHttpClientProvider
|
||||
var clientType = downloadClientConfig.TypeName switch
|
||||
{
|
||||
DownloadClientTypeName.Deluge => HttpClientType.Deluge,
|
||||
DownloadClientTypeName.uTorrent => HttpClientType.UTorrent,
|
||||
_ => HttpClientType.WithRetry
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,5 +36,6 @@ public enum HttpClientType
|
||||
{
|
||||
Default,
|
||||
WithRetry,
|
||||
Deluge
|
||||
Deluge,
|
||||
UTorrent,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
2180
code/backend/Cleanuparr.Persistence/Migrations/Data/20260527073208_AddOrphanedFilesCleanup.Designer.cs
generated
Normal file
2180
code/backend/Cleanuparr.Persistence/Migrations/Data/20260527073208_AddOrphanedFilesCleanup.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
1345
code/frontend/package-lock.json
generated
1345
code/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()">
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user