mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-06-09 14:25:43 -04:00
Add orphaned files cleanup (#618)
This commit is contained in:
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
|
||||
- name: Start services
|
||||
working-directory: e2e
|
||||
run: docker compose -f docker-compose.e2e.yml up -d --build
|
||||
run: make up
|
||||
env:
|
||||
PACKAGES_USERNAME: ${{ github.repository_owner }}
|
||||
PACKAGES_PAT: ${{ env.PACKAGES_PAT }}
|
||||
|
||||
@@ -34,6 +34,7 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
|
||||
> - Search for **custom format score upgrades** with automatic score tracking.
|
||||
> - Clean up downloads that have been **seeding** for a certain amount of time.
|
||||
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).
|
||||
> - Scan configured directories for **files not claimed by any active torrent**, move them to a dedicated orphaned directory, and optionally auto-purge.
|
||||
> - Notify on strike or download removal.
|
||||
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -138,6 +138,18 @@ Advanced download management and automation features for your *arr applications
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
id="orphaned-files-scanner"
|
||||
title="Orphaned Files Scanner"
|
||||
description="Find files on disk that aren't claimed by any active torrent and move them aside"
|
||||
icon="file-off"
|
||||
>
|
||||
|
||||
- Scan configured directories for **files and folders not referenced by any active torrent** across all download clients.
|
||||
- Move detected orphans to a dedicated **Orphaned Directory** for later review.
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
id="notifications"
|
||||
title="Notification System"
|
||||
|
||||
@@ -208,6 +208,113 @@ When enabled, the source files will be deleted from disk when the download is re
|
||||
|
||||
<div className={styles.section}>
|
||||
|
||||
<SectionTitle>Orphaned Files</SectionTitle>
|
||||
|
||||
<p className={styles.sectionDescription}>
|
||||
The Orphaned Files feature scans configured directories for files and directories no longer tracked by any active torrent and moves them to a dedicated orphaned directory. It runs as part of the Download Cleaner job and shares its schedule. Supported for all download clients: qBittorrent, Transmission, Deluge, rTorrent, and uTorrent.
|
||||
</p>
|
||||
|
||||
<p className={styles.sectionDescription}>
|
||||
All Orphaned Files settings are configured per download client in the <strong>Orphaned Files</strong> accordion inside the per-client section.
|
||||
</p>
|
||||
|
||||
<ConfigSection
|
||||
title="Exclude Patterns"
|
||||
>
|
||||
|
||||
Glob patterns for file or directory names that should never be considered orphaned, even if no active torrent claims them. Matching is case-insensitive and applied to the entry name only, not the full path.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
*.nfo
|
||||
*.txt
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
```
|
||||
|
||||
<Note>
|
||||
Use `*` as a wildcard for any characters. For example, `*.nfo` matches any file ending in `.nfo` regardless of its location inside the scan directory.
|
||||
</Note>
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="Min File Age"
|
||||
>
|
||||
|
||||
Minimum age in hours a file or directory must have before it can be considered orphaned. This protects files that are actively being downloaded or have just finished — they may not yet be registered as a torrent save path.
|
||||
|
||||
Defaults to `24` hours. Set to `0` to disable the age check.
|
||||
|
||||
**Example:** Set to `1` to skip any entry modified less than an hour ago.
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="Purge Orphaned After"
|
||||
>
|
||||
|
||||
Number of hours after which entries in the Orphaned Directory are permanently deleted. Leave empty to keep orphaned files indefinitely.
|
||||
|
||||
**Example:** Set to `720` to automatically purge orphaned files older than 30 days.
|
||||
|
||||
<Warning>
|
||||
Files deleted by this option are permanently removed from disk. Review the contents of your Orphaned Directory before enabling this setting.
|
||||
</Warning>
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="Enabled (per client)"
|
||||
>
|
||||
|
||||
Enable orphaned files scanning for this specific download client. The download client must also be enabled in Download Client settings.
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="Scan Directories"
|
||||
>
|
||||
|
||||
Absolute paths to scan for orphaned files for this download client. Each top-level entry inside these directories is checked against the save paths of all active torrents across all enabled clients. If no active torrent claims an entry, it is considered orphaned.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
/data/downloads/completed
|
||||
/data/downloads/cross-seed
|
||||
```
|
||||
|
||||
<Important>
|
||||
Each path must be accessible by Cleanuparr. If running in Docker, make sure to mount the directories accordingly.
|
||||
</Important>
|
||||
|
||||
<Important>
|
||||
Scan directories must not overlap (be equal to, a parent of, or a subdirectory of) the scan directories or orphaned directory of any other download client. Cleanuparr enforces this at save time to prevent cross-client false positives.
|
||||
</Important>
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="Orphaned Directory"
|
||||
>
|
||||
|
||||
The directory where orphaned files and directories are moved for this download client. Required when the orphaned files cleanup is enabled.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
/data/downloads/orphaned
|
||||
```
|
||||
|
||||
<Note>
|
||||
The orphaned directory itself is never scanned for orphans. If the destination already contains an entry with the same name, a timestamp suffix is appended automatically to avoid collisions.
|
||||
</Note>
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
|
||||
<SectionTitle>Unlinked Download Settings</SectionTitle>
|
||||
|
||||
<p className={styles.sectionDescription}>
|
||||
|
||||
1
e2e/.gitignore
vendored
1
e2e/.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
test-data/
|
||||
|
||||
11
e2e/Makefile
11
e2e/Makefile
@@ -1,10 +1,13 @@
|
||||
.PHONY: up down test install
|
||||
.PHONY: up down test install setup
|
||||
|
||||
up:
|
||||
docker compose -f docker-compose.e2e.yml up -d --build
|
||||
setup:
|
||||
bash ./scripts/setup-test-data.sh
|
||||
|
||||
up: setup
|
||||
docker compose -f docker-compose.e2e.yml up -d --build --remove-orphans
|
||||
|
||||
down:
|
||||
docker compose -f docker-compose.e2e.yml down
|
||||
docker compose -f docker-compose.e2e.yml down -v
|
||||
|
||||
install:
|
||||
npm install
|
||||
|
||||
@@ -35,6 +35,8 @@ services:
|
||||
HTTP_PORTS: "5000"
|
||||
tmpfs:
|
||||
- /config
|
||||
volumes:
|
||||
- ./test-data/downloads:/e2e-downloads
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
@@ -43,3 +45,100 @@ services:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
qbittorrent:
|
||||
image: lscr.io/linuxserver/qbittorrent:4.6.7
|
||||
network_mode: host
|
||||
environment:
|
||||
PUID: "1000"
|
||||
PGID: "1000"
|
||||
TZ: "UTC"
|
||||
WEBUI_PORT: "8090"
|
||||
TORRENTING_PORT: "6881"
|
||||
volumes:
|
||||
- ./test-data/qbittorrent-config:/config
|
||||
- ./test-data/downloads/qbittorrent:/downloads
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O- http://localhost:8090/api/v2/app/version > /dev/null 2>&1 || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 60
|
||||
start_period: 30s
|
||||
|
||||
transmission:
|
||||
image: lscr.io/linuxserver/transmission:4.0.6
|
||||
network_mode: host
|
||||
environment:
|
||||
PUID: "1000"
|
||||
PGID: "1000"
|
||||
TZ: "UTC"
|
||||
USER: "transmission"
|
||||
PASS: "transmission"
|
||||
PEERPORT: "51413"
|
||||
volumes:
|
||||
- ./test-data/transmission-config:/config
|
||||
- ./test-data/downloads/transmission:/downloads
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O- http://localhost:9091/transmission/rpc > /dev/null 2>&1 ; [ $$? -eq 8 ] || [ $$? -eq 0 ]"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 60
|
||||
start_period: 30s
|
||||
|
||||
deluge:
|
||||
image: lscr.io/linuxserver/deluge:2.1.1
|
||||
network_mode: host
|
||||
environment:
|
||||
PUID: "1000"
|
||||
PGID: "1000"
|
||||
TZ: "UTC"
|
||||
DELUGE_LOGLEVEL: "info"
|
||||
volumes:
|
||||
- ./test-data/deluge-config:/config
|
||||
- ./test-data/downloads/deluge:/downloads
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O- http://localhost:8112 > /dev/null 2>&1 || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 60
|
||||
start_period: 30s
|
||||
|
||||
utorrent:
|
||||
image: ekho/utorrent:latest
|
||||
platform: linux/amd64
|
||||
ports:
|
||||
- "8083:8080"
|
||||
environment:
|
||||
UID: "1000"
|
||||
GID: "1000"
|
||||
dir_root: "/downloads"
|
||||
dir_active: "/downloads"
|
||||
dir_completed: "/downloads"
|
||||
dir_download: "/downloads"
|
||||
volumes:
|
||||
- ./test-data/utorrent-config:/data
|
||||
- ./test-data/downloads/utorrent:/downloads
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O- http://localhost:8080/gui/ > /dev/null 2>&1 || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 60
|
||||
start_period: 30s
|
||||
|
||||
rutorrent:
|
||||
image: lscr.io/linuxserver/rutorrent:latest
|
||||
ports:
|
||||
- "8088:80"
|
||||
environment:
|
||||
PUID: "1000"
|
||||
PGID: "1000"
|
||||
TZ: "UTC"
|
||||
volumes:
|
||||
- ./test-data/rutorrent-config:/config
|
||||
- ./test-data/downloads/rtorrent:/downloads
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O- http://localhost > /dev/null 2>&1 || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 60
|
||||
start_period: 60s
|
||||
|
||||
44
e2e/scripts/setup-test-data.sh
Normal file
44
e2e/scripts/setup-test-data.sh
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Prepare the e2e/test-data tree before `docker compose up`.
|
||||
#
|
||||
# Re-creates the qBittorrent config from scratch on every run
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TEST_DATA="$HERE/test-data"
|
||||
|
||||
mkdir -p \
|
||||
"$TEST_DATA/downloads/qbittorrent" \
|
||||
"$TEST_DATA/downloads/transmission" \
|
||||
"$TEST_DATA/downloads/deluge" \
|
||||
"$TEST_DATA/downloads/utorrent" \
|
||||
"$TEST_DATA/downloads/rtorrent" \
|
||||
"$TEST_DATA/qbittorrent-config/qBittorrent" \
|
||||
"$TEST_DATA/transmission-config" \
|
||||
"$TEST_DATA/deluge-config" \
|
||||
"$TEST_DATA/utorrent-config" \
|
||||
"$TEST_DATA/rutorrent-config"
|
||||
|
||||
chmod -R a+rwX "$TEST_DATA" 2>/dev/null || true
|
||||
|
||||
# qBittorrent credentials: admin / adminadmin
|
||||
cat > "$TEST_DATA/qbittorrent-config/qBittorrent/qBittorrent.conf" <<'EOF'
|
||||
[LegalNotice]
|
||||
Accepted=true
|
||||
|
||||
[Preferences]
|
||||
WebUI\Port=8090
|
||||
WebUI\Address=*
|
||||
WebUI\CSRFProtection=false
|
||||
WebUI\HostHeaderValidation=false
|
||||
WebUI\LocalHostAuth=false
|
||||
WebUI\AuthSubnetWhitelistEnabled=true
|
||||
WebUI\AuthSubnetWhitelist=127.0.0.0/8, ::1/128
|
||||
WebUI\Username=admin
|
||||
WebUI\Password_PBKDF2="@ByteArray(ARQ77eY1NUZ366igo9pHIQ==:Bn3qWLqOY3qE6Z+sCx2NoO5q4nhgxhUL3eRD4Zw3+5p9C7+RmrI20bzAjcwHKqcWa+5z6QBQGckCB8sFCnVTGw==)"
|
||||
Downloads\SavePath=/downloads
|
||||
EOF
|
||||
|
||||
echo "test-data ready under $TEST_DATA"
|
||||
221
e2e/tests/16-orphaned-files-cleanup.spec.ts
Normal file
221
e2e/tests/16-orphaned-files-cleanup.spec.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import {
|
||||
loginAndGetToken,
|
||||
createDownloadClient,
|
||||
listDownloadClients,
|
||||
deleteDownloadClient,
|
||||
updateDownloadCleanerConfig,
|
||||
getDownloadCleanerConfig,
|
||||
updateOrphanedFilesConfig,
|
||||
triggerJob,
|
||||
} from './helpers/app-api';
|
||||
import { ALL_CLIENTS, TorrentClientFixture } from './helpers/torrent-clients';
|
||||
import { buildFolderTorrent, chmodIgnoringEPERM, resetDirectory } from './helpers/torrent-fixtures';
|
||||
|
||||
async function waitForTorrents(
|
||||
driver: { listTorrents(): Promise<Array<{ hash: string }>> },
|
||||
expectedHashes: string[],
|
||||
timeoutMs = 15_000,
|
||||
): Promise<void> {
|
||||
const want = new Set(expectedHashes.map((h) => h.toLowerCase()));
|
||||
const start = Date.now();
|
||||
let last: Set<string> = new Set();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const list = await driver.listTorrents();
|
||||
last = new Set(list.map((t) => t.hash.toLowerCase()));
|
||||
if ([...want].every((h) => last.has(h))) return;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
const missing = [...want].filter((h) => !last.has(h));
|
||||
throw new Error(`Torrents missing after ${timeoutMs}ms: ${missing.join(', ')} (saw [${[...last].join(', ')}])`);
|
||||
}
|
||||
|
||||
async function waitForOrphanMove(dir: string, expectedName: string, timeoutMs = 45_000): Promise<string> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (existsSync(dir)) {
|
||||
const entries = readdirSync(dir);
|
||||
const moved = entries.find((e) => e === expectedName || e.startsWith(`${expectedName}_`));
|
||||
if (moved) return moved;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
throw new Error(`Timed out waiting for orphan "${expectedName}" to appear under ${dir}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphaned files cleanup e2e — exercises the full pipeline for every
|
||||
* supported download client:
|
||||
*
|
||||
* 1. configure the download cleaner globally (enabled, generous schedule)
|
||||
* 2. configure the orphaned files cleanup globally (no min age, no purge)
|
||||
* 3. spin up the client and pre-create two torrents whose data lives in
|
||||
* /e2e-downloads/<client>/
|
||||
* 4. delete one of those torrents through the client's API while keeping
|
||||
* data on disk → produces a real orphan
|
||||
* 5. trigger the DownloadCleaner job
|
||||
* 6. assert the surviving torrent's files are untouched and the orphan's
|
||||
* files were moved into /e2e-downloads/<client>/orphaned/
|
||||
*
|
||||
* The downloads volume is bind-mounted at the same path inside every
|
||||
* container (`/e2e-downloads`) and on the host (`e2e/test-data/downloads`)
|
||||
* so the spec can assert directly against host paths without any
|
||||
* DownloadDirectorySource/Target remapping.
|
||||
*/
|
||||
|
||||
const HOST_DOWNLOADS = resolve(__dirname, '..', 'test-data', 'downloads');
|
||||
const CLIENT_DOWNLOADS = '/downloads';
|
||||
const APP_DOWNLOADS = '/e2e-downloads';
|
||||
|
||||
function clientDirs(slug: string) {
|
||||
return {
|
||||
hostScanDir: join(HOST_DOWNLOADS, slug),
|
||||
hostOrphanedDir: join(HOST_DOWNLOADS, slug, 'orphaned'),
|
||||
clientSavePath: CLIENT_DOWNLOADS,
|
||||
appScanDir: `${APP_DOWNLOADS}/${slug}`,
|
||||
appOrphanedDir: `${APP_DOWNLOADS}/${slug}/orphaned`,
|
||||
};
|
||||
}
|
||||
|
||||
const SLUG_BY_TYPE: Record<string, string> = {
|
||||
qBittorrent: 'qbittorrent',
|
||||
Transmission: 'transmission',
|
||||
Deluge: 'deluge',
|
||||
uTorrent: 'utorrent',
|
||||
rTorrent: 'rtorrent',
|
||||
};
|
||||
|
||||
test.describe.serial('Orphaned files cleanup', () => {
|
||||
let token: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
token = await loginAndGetToken();
|
||||
|
||||
// Reset all existing download clients so the spec starts from a clean slate.
|
||||
const existing = await listDownloadClients(token);
|
||||
for (const client of existing) {
|
||||
await deleteDownloadClient(token, client.id);
|
||||
}
|
||||
|
||||
// Enable the global download cleaner + the global orphaned-files config.
|
||||
// Schedule is irrelevant since we trigger the job manually.
|
||||
const dcCurrent = await (await getDownloadCleanerConfig(token)).json();
|
||||
await updateDownloadCleanerConfig(token, {
|
||||
enabled: true,
|
||||
cronExpression: dcCurrent.cronExpression || '0 0 * * * ?',
|
||||
useAdvancedScheduling: dcCurrent.useAdvancedScheduling ?? false,
|
||||
ignoredDownloads: [],
|
||||
});
|
||||
|
||||
mkdirSync(HOST_DOWNLOADS, { recursive: true });
|
||||
});
|
||||
|
||||
for (const fixture of ALL_CLIENTS) {
|
||||
runClientScenario(fixture, () => token);
|
||||
}
|
||||
});
|
||||
|
||||
function runClientScenario(fixture: TorrentClientFixture, getToken: () => string) {
|
||||
const { driver } = fixture;
|
||||
const slug = SLUG_BY_TYPE[driver.typeName];
|
||||
const describeFn = fixture.enabled ? test.describe : test.describe.skip;
|
||||
|
||||
describeFn(`${driver.typeName}`, () => {
|
||||
let keep: { name: string; infoHash: string };
|
||||
let orphan: { name: string; infoHash: string };
|
||||
let clientId: string;
|
||||
const dirs = clientDirs(slug);
|
||||
|
||||
test('configures client and produces an orphan', async () => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
// Fresh per-client scan dir so a previous failed run doesn't bleed in.
|
||||
resetDirectory(dirs.hostScanDir);
|
||||
mkdirSync(dirs.hostOrphanedDir, { recursive: true });
|
||||
chmodIgnoringEPERM(dirs.hostOrphanedDir, 0o777);
|
||||
|
||||
const keepName = `keep-${slug}`;
|
||||
const orphanName = `orphan-${slug}`;
|
||||
const keepFx = buildFolderTorrent(dirs.hostScanDir, keepName);
|
||||
const orphanFx = buildFolderTorrent(dirs.hostScanDir, orphanName);
|
||||
keep = { name: keepName, infoHash: keepFx.infoHash };
|
||||
orphan = { name: orphanName, infoHash: orphanFx.infoHash };
|
||||
|
||||
// Wait for the client's HTTP surface to come up. This is the slowest
|
||||
// step on a cold compose start.
|
||||
await driver.ready();
|
||||
|
||||
// Wipe any torrents left over from a prior `make test` run — the
|
||||
// client's session is in a persistent config volume that survives
|
||||
// `make test` and would otherwise reject re-adding the same infohash.
|
||||
await driver.clearAllTorrents();
|
||||
|
||||
const createRes = await createDownloadClient(getToken(), {
|
||||
enabled: true,
|
||||
name: `${driver.typeName} e2e`,
|
||||
typeName: driver.typeName,
|
||||
type: 'Torrent',
|
||||
host: driver.cleanuparrHost,
|
||||
username: driver.username ?? '',
|
||||
password: driver.password ?? '',
|
||||
downloadDirectorySource: dirs.clientSavePath,
|
||||
downloadDirectoryTarget: dirs.appScanDir,
|
||||
});
|
||||
expect(createRes.status).toBeGreaterThanOrEqual(200);
|
||||
expect(createRes.status).toBeLessThan(300);
|
||||
const createdClient = await createRes.json();
|
||||
clientId = createdClient.id;
|
||||
|
||||
const ofcRes = await updateOrphanedFilesConfig(getToken(), clientId, {
|
||||
enabled: true,
|
||||
scanDirectories: [dirs.appScanDir],
|
||||
orphanedDirectory: dirs.appOrphanedDir,
|
||||
minFileAgeHours: 0,
|
||||
});
|
||||
expect(ofcRes.status).toBe(200);
|
||||
|
||||
await driver.addTorrent({
|
||||
metainfo: keepFx.metainfo,
|
||||
savePath: dirs.clientSavePath,
|
||||
name: keepName,
|
||||
infoHash: keepFx.infoHash,
|
||||
});
|
||||
await driver.addTorrent({
|
||||
metainfo: orphanFx.metainfo,
|
||||
savePath: dirs.clientSavePath,
|
||||
name: orphanName,
|
||||
infoHash: orphanFx.infoHash,
|
||||
});
|
||||
|
||||
// Some clients process `add` asynchronously — poll for both torrents
|
||||
// to become visible before continuing.
|
||||
await waitForTorrents(driver, [keep.infoHash, orphan.infoHash]);
|
||||
|
||||
// Delete the orphan torrent from the client while preserving data.
|
||||
await driver.deleteTorrent(orphan.infoHash);
|
||||
|
||||
// Verify orphan is gone from the client but still present on disk.
|
||||
const afterList = await driver.listTorrents();
|
||||
const afterHashes = new Set(afterList.map((t) => t.hash.toLowerCase()));
|
||||
expect(afterHashes.has(keep.infoHash.toLowerCase())).toBe(true);
|
||||
expect(afterHashes.has(orphan.infoHash.toLowerCase())).toBe(false);
|
||||
expect(existsSync(join(dirs.hostScanDir, orphanName))).toBe(true);
|
||||
|
||||
// Trigger the cleaner. The job runs async on a worker thread; we poll
|
||||
// the filesystem for the expected outcome rather than sleeping.
|
||||
const trig = await triggerJob(getToken(), 'DownloadCleaner');
|
||||
expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true);
|
||||
|
||||
const moved = await waitForOrphanMove(dirs.hostOrphanedDir, orphanName);
|
||||
|
||||
// Assert: kept torrent's folder survives in place.
|
||||
expect(existsSync(join(dirs.hostScanDir, keepName, 'data.bin'))).toBe(true);
|
||||
// Assert: orphan folder no longer at top of scan dir.
|
||||
expect(existsSync(join(dirs.hostScanDir, orphanName))).toBe(false);
|
||||
// Assert: orphan folder is under the orphanedDirectory, with its data intact.
|
||||
expect(existsSync(join(dirs.hostOrphanedDir, moved, 'data.bin'))).toBe(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
256
e2e/tests/17-orphaned-files-behaviors.spec.ts
Normal file
256
e2e/tests/17-orphaned-files-behaviors.spec.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { existsSync, mkdirSync, readdirSync, statSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import {
|
||||
loginAndGetToken,
|
||||
createDownloadClient,
|
||||
listDownloadClients,
|
||||
deleteDownloadClient,
|
||||
updateDownloadCleanerConfig,
|
||||
getDownloadCleanerConfig,
|
||||
updateOrphanedFilesConfig,
|
||||
getGeneralConfig,
|
||||
updateGeneralConfig,
|
||||
triggerJob,
|
||||
OrphanedFilesConfigRequest,
|
||||
} from './helpers/app-api';
|
||||
import { QBittorrentDriver } from './helpers/torrent-clients/qbittorrent';
|
||||
import { chmodIgnoringEPERM, resetDirectory } from './helpers/torrent-fixtures';
|
||||
|
||||
/**
|
||||
* Behavior-level coverage for the orphaned files cleaner that isn't
|
||||
* client-specific. The per-client integration matrix lives in
|
||||
* `16-orphaned-files-cleanup.spec.ts`; this file picks qBittorrent as the
|
||||
* single backing client and exercises configuration knobs:
|
||||
*
|
||||
* - PurgeAfterHours (deletes aged, leaves recent, null = never purge)
|
||||
* - MinFileAgeHours (skips fresh entries)
|
||||
* - ExcludePatterns
|
||||
* - Per-client config disabled = no-op
|
||||
* - DryRun = read-only
|
||||
*
|
||||
* "Aged" is simulated by backdating mtime via `utimesSync` after the file
|
||||
* exists. This is reliable for the purge path (which only consults
|
||||
* `GetLastWriteTimeUtc`) but not for the move path's MinFileAgeHours check,
|
||||
* which compares against `MAX(lastWrite, created)` — Linux birthtime
|
||||
* cannot be portably backdated. That scenario is covered by unit tests.
|
||||
*/
|
||||
|
||||
const HOST_DOWNLOADS = resolve(__dirname, '..', 'test-data', 'downloads');
|
||||
const APP_DOWNLOADS = '/e2e-downloads';
|
||||
const SLUG = 'qbittorrent-behaviors';
|
||||
const HOST_SCAN_DIR = join(HOST_DOWNLOADS, SLUG);
|
||||
const HOST_ORPHANED_DIR = join(HOST_DOWNLOADS, SLUG, 'orphaned');
|
||||
const APP_SCAN_DIR = `${APP_DOWNLOADS}/${SLUG}`;
|
||||
const APP_ORPHANED_DIR = `${APP_DOWNLOADS}/${SLUG}/orphaned`;
|
||||
|
||||
function backdateRecursive(path: string, hoursAgo: number): void {
|
||||
const t = (Date.now() - hoursAgo * 3600_000) / 1000;
|
||||
const visit = (p: string) => {
|
||||
utimesSync(p, t, t);
|
||||
if (statSync(p).isDirectory()) {
|
||||
for (const e of readdirSync(p)) visit(join(p, e));
|
||||
}
|
||||
};
|
||||
visit(path);
|
||||
}
|
||||
|
||||
function writeOrphanFile(dir: string, name: string, content = 'orphan'): string {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
chmodIgnoringEPERM(dir, 0o777);
|
||||
const path = join(dir, name);
|
||||
writeFileSync(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
async function waitForCondition(
|
||||
predicate: () => boolean | Promise<boolean>,
|
||||
timeoutMs: number,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await predicate()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for: ${label}`);
|
||||
}
|
||||
|
||||
async function triggerAndSettle(token: string): Promise<void> {
|
||||
const res = await triggerJob(token, 'DownloadCleaner');
|
||||
expect(res.ok, `triggerJob: ${res.status}`).toBe(true);
|
||||
// The cleaner is async on a worker thread. Give it time to walk the dirs.
|
||||
// No seeding downloads means no 10s arr-settle delay — a couple of seconds
|
||||
// is plenty in practice, but we still poll where it matters.
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
}
|
||||
|
||||
test.describe.serial('Orphaned files cleanup — behaviors', () => {
|
||||
const driver = new QBittorrentDriver();
|
||||
let token: string;
|
||||
let clientId: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
token = await loginAndGetToken();
|
||||
|
||||
// Clean slate: remove any leftover clients from other specs.
|
||||
const existing = await listDownloadClients(token);
|
||||
for (const client of existing) {
|
||||
await deleteDownloadClient(token, client.id);
|
||||
}
|
||||
|
||||
// Enable the global download cleaner. Schedule is irrelevant — we
|
||||
// trigger the job manually.
|
||||
const dcCurrent = await (await getDownloadCleanerConfig(token)).json();
|
||||
await updateDownloadCleanerConfig(token, {
|
||||
enabled: true,
|
||||
cronExpression: dcCurrent.cronExpression || '0 0 * * * ?',
|
||||
useAdvancedScheduling: dcCurrent.useAdvancedScheduling ?? false,
|
||||
ignoredDownloads: [],
|
||||
});
|
||||
|
||||
mkdirSync(HOST_DOWNLOADS, { recursive: true });
|
||||
|
||||
// Bring up qBittorrent and register it with Cleanuparr.
|
||||
await driver.ready();
|
||||
await driver.clearAllTorrents();
|
||||
|
||||
const createRes = await createDownloadClient(token, {
|
||||
enabled: true,
|
||||
name: 'qBittorrent behaviors',
|
||||
typeName: driver.typeName,
|
||||
type: 'Torrent',
|
||||
host: driver.cleanuparrHost,
|
||||
username: driver.username ?? '',
|
||||
password: driver.password ?? '',
|
||||
downloadDirectorySource: '/downloads',
|
||||
downloadDirectoryTarget: APP_SCAN_DIR,
|
||||
});
|
||||
expect(createRes.ok, `createDownloadClient: ${createRes.status}`).toBe(true);
|
||||
const created = await createRes.json();
|
||||
clientId = created.id;
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
// Reset filesystem state before each scenario.
|
||||
resetDirectory(HOST_SCAN_DIR);
|
||||
mkdirSync(HOST_ORPHANED_DIR, { recursive: true });
|
||||
chmodIgnoringEPERM(HOST_ORPHANED_DIR, 0o777);
|
||||
// No torrents in the client → claimedPaths is empty → every entry in
|
||||
// scan dir is treated as orphan.
|
||||
await driver.clearAllTorrents();
|
||||
});
|
||||
|
||||
const configureOrphanedFiles = async (
|
||||
overrides: Partial<OrphanedFilesConfigRequest> = {},
|
||||
): Promise<void> => {
|
||||
const config: OrphanedFilesConfigRequest = {
|
||||
enabled: true,
|
||||
scanDirectories: [APP_SCAN_DIR],
|
||||
orphanedDirectory: APP_ORPHANED_DIR,
|
||||
excludePatterns: [],
|
||||
minFileAgeHours: 0,
|
||||
purgeAfterHours: null,
|
||||
...overrides,
|
||||
};
|
||||
const res = await updateOrphanedFilesConfig(token, clientId, config);
|
||||
expect(res.ok, `updateOrphanedFilesConfig: ${res.status}`).toBe(true);
|
||||
};
|
||||
|
||||
test('PurgeAfterHours deletes aged entries from the orphaned directory', async () => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const aged = writeOrphanFile(HOST_ORPHANED_DIR, 'aged.bin');
|
||||
backdateRecursive(aged, 25);
|
||||
await configureOrphanedFiles({ purgeAfterHours: 24 });
|
||||
|
||||
await triggerAndSettle(token);
|
||||
await waitForCondition(() => !existsSync(aged), 10_000, `purge of ${aged}`);
|
||||
});
|
||||
|
||||
test('PurgeAfterHours leaves entries newer than the cutoff', async () => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const fresh = writeOrphanFile(HOST_ORPHANED_DIR, 'fresh.bin');
|
||||
await configureOrphanedFiles({ purgeAfterHours: 24 });
|
||||
|
||||
await triggerAndSettle(token);
|
||||
expect(existsSync(fresh)).toBe(true);
|
||||
});
|
||||
|
||||
test('PurgeAfterHours null never purges, even very old entries', async () => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const ancient = writeOrphanFile(HOST_ORPHANED_DIR, 'ancient.bin');
|
||||
backdateRecursive(ancient, 24 * 365);
|
||||
await configureOrphanedFiles({ purgeAfterHours: null });
|
||||
|
||||
await triggerAndSettle(token);
|
||||
expect(existsSync(ancient)).toBe(true);
|
||||
});
|
||||
|
||||
test('MinFileAgeHours skips fresh entries in the scan directory', async () => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const fresh = writeOrphanFile(HOST_SCAN_DIR, 'too-fresh.bin');
|
||||
await configureOrphanedFiles({ minFileAgeHours: 1 });
|
||||
|
||||
await triggerAndSettle(token);
|
||||
// Still in the scan dir, not moved to orphaned dir.
|
||||
expect(existsSync(fresh)).toBe(true);
|
||||
expect(existsSync(join(HOST_ORPHANED_DIR, 'too-fresh.bin'))).toBe(false);
|
||||
});
|
||||
|
||||
test('ExcludePatterns prevents matching entries from being moved', async () => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const excluded = writeOrphanFile(HOST_SCAN_DIR, 'metadata.nfo');
|
||||
const matched = writeOrphanFile(HOST_SCAN_DIR, 'real-orphan.bin');
|
||||
await configureOrphanedFiles({ excludePatterns: ['*.nfo'] });
|
||||
|
||||
await triggerAndSettle(token);
|
||||
await waitForCondition(
|
||||
() => !existsSync(matched),
|
||||
10_000,
|
||||
'real-orphan.bin to be moved',
|
||||
);
|
||||
// .nfo file untouched.
|
||||
expect(existsSync(excluded)).toBe(true);
|
||||
});
|
||||
|
||||
test('Disabled per-client config is a no-op', async () => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const orphan = writeOrphanFile(HOST_SCAN_DIR, 'leave-me.bin');
|
||||
await configureOrphanedFiles({ enabled: false });
|
||||
|
||||
await triggerAndSettle(token);
|
||||
expect(existsSync(orphan)).toBe(true);
|
||||
expect(existsSync(join(HOST_ORPHANED_DIR, 'leave-me.bin'))).toBe(false);
|
||||
});
|
||||
|
||||
test.describe('DryRun', () => {
|
||||
test.afterEach(async () => {
|
||||
// Always clear dry-run so it doesn't leak into subsequent specs.
|
||||
const current = await getGeneralConfig(token);
|
||||
await updateGeneralConfig(token, { ...current, dryRun: false });
|
||||
});
|
||||
|
||||
test('DryRun skips filesystem mutations', async () => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const orphan = writeOrphanFile(HOST_SCAN_DIR, 'pretend-only.bin');
|
||||
|
||||
const current = await getGeneralConfig(token);
|
||||
await updateGeneralConfig(token, { ...current, dryRun: true });
|
||||
|
||||
await configureOrphanedFiles();
|
||||
|
||||
await triggerAndSettle(token);
|
||||
expect(existsSync(orphan)).toBe(true);
|
||||
expect(existsSync(join(HOST_ORPHANED_DIR, 'pretend-only.bin'))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -304,6 +304,55 @@ export async function deleteDownloadClient(accessToken: string, clientId: string
|
||||
});
|
||||
}
|
||||
|
||||
export async function listDownloadClients(accessToken: string): Promise<Array<{ id: string; name: string; typeName: string }>> {
|
||||
const res = await fetch(`${API}/api/configuration/download_client`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to list download clients: ${res.status}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
return body.clients ?? [];
|
||||
}
|
||||
|
||||
// --- Orphaned files cleanup helpers ---
|
||||
|
||||
export interface OrphanedFilesConfigRequest {
|
||||
enabled: boolean;
|
||||
scanDirectories: string[];
|
||||
orphanedDirectory: string;
|
||||
excludePatterns?: string[];
|
||||
minFileAgeHours?: number;
|
||||
purgeAfterHours?: number | null;
|
||||
}
|
||||
|
||||
export async function updateOrphanedFilesConfig(
|
||||
accessToken: string,
|
||||
downloadClientId: string,
|
||||
config: OrphanedFilesConfigRequest,
|
||||
): Promise<Response> {
|
||||
return fetch(`${API}/api/orphaned-files-config/${downloadClientId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Job triggering ---
|
||||
|
||||
export async function triggerJob(
|
||||
accessToken: string,
|
||||
jobType: 'QueueCleaner' | 'MalwareBlocker' | 'DownloadCleaner' | 'BlacklistSynchronizer' | 'CustomFormatScoreSyncer',
|
||||
): Promise<Response> {
|
||||
return fetch(`${API}/api/jobs/${jobType}/trigger`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
|
||||
// --- General config / auth-bypass helpers ---
|
||||
|
||||
export async function getGeneralConfig(accessToken: string): Promise<Record<string, unknown>> {
|
||||
|
||||
112
e2e/tests/helpers/torrent-clients/deluge.ts
Normal file
112
e2e/tests/helpers/torrent-clients/deluge.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { TorrentClientDriver, pollUntilOk } from './types';
|
||||
|
||||
/**
|
||||
* Deluge driver (Web UI JSON-RPC at /json).
|
||||
*
|
||||
* Auth flow:
|
||||
* - POST { method: 'auth.login', params: [password] } — sets session cookie
|
||||
* - POST { method: 'web.connected' } — true once Web UI is connected to a daemon
|
||||
* - POST { method: 'web.connect', params: [host_id] } — pick the first
|
||||
* daemon if Web UI isn't connected yet
|
||||
*
|
||||
* Default linuxserver/deluge web password is `deluge`.
|
||||
*/
|
||||
export class DelugeDriver implements TorrentClientDriver {
|
||||
readonly typeName = 'Deluge' as const;
|
||||
readonly cleanuparrHost: string;
|
||||
readonly username = '';
|
||||
readonly password: string;
|
||||
private readonly directJson: string;
|
||||
private cookie: string | null = null;
|
||||
private requestId = 1;
|
||||
|
||||
constructor(host = 'http://localhost:8112', password = 'deluge') {
|
||||
this.cleanuparrHost = host;
|
||||
this.password = password;
|
||||
this.directJson = `${host.replace(/\/$/, '')}/json`;
|
||||
}
|
||||
|
||||
async ready(): Promise<void> {
|
||||
await pollUntilOk(
|
||||
async () => {
|
||||
const res = await fetch(this.directJson, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ method: 'auth.login', params: [this.password], id: this.requestId++ }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const setCookie = res.headers.get('set-cookie');
|
||||
if (setCookie) this.cookie = setCookie.split(';')[0];
|
||||
const body = await res.json();
|
||||
return body.result === true;
|
||||
},
|
||||
{ label: 'Deluge Web UI' },
|
||||
);
|
||||
|
||||
// Ensure Web UI is bound to the local daemon. On a fresh install the
|
||||
// Web UI starts unconnected and `core.*` calls fail until we connect.
|
||||
const connected = await this.call<boolean>('web.connected', []);
|
||||
if (!connected) {
|
||||
const hosts = await this.call<Array<Array<string>>>('web.get_hosts', []);
|
||||
const firstHost = hosts?.[0]?.[0];
|
||||
if (!firstHost) {
|
||||
throw new Error('Deluge Web UI has no daemon to connect to (web.get_hosts returned empty)');
|
||||
}
|
||||
await this.call('web.connect', [firstHost]);
|
||||
const connectedAfter = await this.call<boolean>('web.connected', []);
|
||||
if (!connectedAfter) {
|
||||
throw new Error('Deluge Web UI is not connected to a daemon after web.connect');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async call<T>(method: string, params: unknown[]): Promise<T> {
|
||||
const res = await fetch(this.directJson, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.cookie ? { Cookie: this.cookie } : {}),
|
||||
},
|
||||
body: JSON.stringify({ method, params, id: this.requestId++ }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Deluge ${method} failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
if (body.error) {
|
||||
throw new Error(`Deluge ${method} error: ${JSON.stringify(body.error)}`);
|
||||
}
|
||||
return body.result as T;
|
||||
}
|
||||
|
||||
async addTorrent({ metainfo, savePath, name }: { metainfo: Buffer; savePath: string; name: string; infoHash: string }): Promise<void> {
|
||||
const filename = `${name}.torrent`;
|
||||
const b64 = metainfo.toString('base64');
|
||||
await this.call('core.add_torrent_file', [
|
||||
filename,
|
||||
b64,
|
||||
{
|
||||
download_location: savePath,
|
||||
add_paused: true,
|
||||
seed_mode: true, // skip hash check — treat as already complete
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
async deleteTorrent(infoHash: string): Promise<void> {
|
||||
// remove_torrent signature: (torrent_id, remove_data: bool)
|
||||
await this.call('core.remove_torrent', [infoHash, false]);
|
||||
}
|
||||
|
||||
async clearAllTorrents(): Promise<void> {
|
||||
const all = await this.listTorrents();
|
||||
for (const t of all) {
|
||||
await this.call('core.remove_torrent', [t.hash, false]);
|
||||
}
|
||||
}
|
||||
|
||||
async listTorrents(): Promise<Array<{ hash: string; name: string }>> {
|
||||
const result = await this.call<Record<string, { name: string }>>('core.get_torrents_status', [{}, ['name']]);
|
||||
return Object.entries(result ?? {}).map(([hash, info]) => ({ hash, name: info.name }));
|
||||
}
|
||||
}
|
||||
25
e2e/tests/helpers/torrent-clients/index.ts
Normal file
25
e2e/tests/helpers/torrent-clients/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { QBittorrentDriver } from './qbittorrent';
|
||||
import { TransmissionDriver } from './transmission';
|
||||
import { DelugeDriver } from './deluge';
|
||||
import { RTorrentDriver } from './rtorrent';
|
||||
import { UTorrentDriver } from './utorrent';
|
||||
import { TorrentClientDriver, TorrentClientType } from './types';
|
||||
|
||||
export { TorrentClientDriver, TorrentClientType };
|
||||
export { ClientNotImplementedError } from './types';
|
||||
|
||||
export interface TorrentClientFixture {
|
||||
driver: TorrentClientDriver;
|
||||
/** Whether the spec should actually run against this driver. */
|
||||
enabled: boolean;
|
||||
/** Reason this client is disabled (shown in test.skip). */
|
||||
skipReason?: string;
|
||||
}
|
||||
|
||||
export const ALL_CLIENTS: TorrentClientFixture[] = [
|
||||
{ driver: new QBittorrentDriver(), enabled: true },
|
||||
{ driver: new TransmissionDriver(), enabled: true },
|
||||
{ driver: new DelugeDriver(), enabled: true },
|
||||
{ driver: new UTorrentDriver(), enabled: true },
|
||||
{ driver: new RTorrentDriver(), enabled: true },
|
||||
];
|
||||
124
e2e/tests/helpers/torrent-clients/qbittorrent.ts
Normal file
124
e2e/tests/helpers/torrent-clients/qbittorrent.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { TorrentClientDriver, pollUntilOk } from './types';
|
||||
|
||||
/**
|
||||
* qBittorrent driver (WebUI v2).
|
||||
*
|
||||
* Auth note: relies on the linuxserver/qbittorrent default of bypassing auth
|
||||
* for localhost. Combined with `network_mode: host`, requests from the test
|
||||
* runner originate from 127.0.0.1, so login is skipped. If running against
|
||||
* a qBittorrent without localhost-bypass, set `username` and `password` and
|
||||
* the driver will POST /api/v2/auth/login.
|
||||
*/
|
||||
export class QBittorrentDriver implements TorrentClientDriver {
|
||||
readonly typeName = 'qBittorrent' as const;
|
||||
readonly cleanuparrHost: string;
|
||||
readonly username?: string;
|
||||
readonly password?: string;
|
||||
private readonly directHost: string;
|
||||
private cookie: string | null = null;
|
||||
|
||||
constructor(host = 'http://localhost:8090', username = 'admin', password = 'adminadmin') {
|
||||
this.cleanuparrHost = host;
|
||||
this.directHost = host;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
async ready(): Promise<void> {
|
||||
await pollUntilOk(
|
||||
async () => {
|
||||
const res = await fetch(`${this.directHost}/api/v2/app/version`, {
|
||||
headers: this.cookie ? { Cookie: this.cookie } : undefined,
|
||||
});
|
||||
return res.ok || res.status === 403;
|
||||
},
|
||||
{ label: 'qBittorrent WebUI' },
|
||||
);
|
||||
if (this.username && this.password) {
|
||||
await this.login();
|
||||
}
|
||||
}
|
||||
|
||||
private async login(): Promise<void> {
|
||||
const body = new URLSearchParams({ username: this.username!, password: this.password! });
|
||||
const res = await fetch(`${this.directHost}/api/v2/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
// qBittorrent returns HTTP 200 with body "Ok." on success and "Fails." on
|
||||
// bad credentials, so we cannot rely on res.ok alone.
|
||||
const responseBody = (await res.text()).trim();
|
||||
if (!res.ok || responseBody !== 'Ok.') {
|
||||
throw new Error(`qBittorrent login failed: ${res.status} ${responseBody}`);
|
||||
}
|
||||
const cookie = res.headers.get('set-cookie');
|
||||
if (cookie) {
|
||||
// Strip flags — Node's fetch returns the full header
|
||||
this.cookie = cookie.split(';')[0];
|
||||
}
|
||||
}
|
||||
|
||||
async addTorrent({ metainfo, savePath }: { metainfo: Buffer; savePath: string; name: string; infoHash: string }): Promise<void> {
|
||||
const form = new FormData();
|
||||
form.append('torrents', new Blob([new Uint8Array(metainfo)]), 'torrent.torrent');
|
||||
form.append('savepath', savePath);
|
||||
form.append('paused', 'true');
|
||||
form.append('skip_checking', 'true');
|
||||
form.append('autoTMM', 'false');
|
||||
const res = await fetch(`${this.directHost}/api/v2/torrents/add`, {
|
||||
method: 'POST',
|
||||
headers: this.cookie ? { Cookie: this.cookie } : undefined,
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`qBittorrent add failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTorrent(infoHash: string): Promise<void> {
|
||||
const body = new URLSearchParams({ hashes: infoHash, deleteFiles: 'false' });
|
||||
const res = await fetch(`${this.directHost}/api/v2/torrents/delete`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(this.cookie ? { Cookie: this.cookie } : {}),
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`qBittorrent delete failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
async clearAllTorrents(): Promise<void> {
|
||||
const all = await this.listTorrents();
|
||||
if (all.length === 0) return;
|
||||
const body = new URLSearchParams({
|
||||
hashes: all.map((t) => t.hash).join('|'),
|
||||
deleteFiles: 'false',
|
||||
});
|
||||
const res = await fetch(`${this.directHost}/api/v2/torrents/delete`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(this.cookie ? { Cookie: this.cookie } : {}),
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`qBittorrent clear failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async listTorrents(): Promise<Array<{ hash: string; name: string }>> {
|
||||
const res = await fetch(`${this.directHost}/api/v2/torrents/info`, {
|
||||
headers: this.cookie ? { Cookie: this.cookie } : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`qBittorrent list failed: ${res.status}`);
|
||||
}
|
||||
const items: Array<{ hash: string; name: string }> = await res.json();
|
||||
return items.map((t) => ({ hash: t.hash, name: t.name }));
|
||||
}
|
||||
}
|
||||
190
e2e/tests/helpers/torrent-clients/rtorrent.ts
Normal file
190
e2e/tests/helpers/torrent-clients/rtorrent.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { TorrentClientDriver, pollUntilOk } from './types';
|
||||
|
||||
/**
|
||||
* rTorrent driver via XML-RPC over HTTP. linuxserver/rutorrent exposes
|
||||
* SCGI-backed XML-RPC at `/RPC2` on the rutorrent web port (8088 by default).
|
||||
*
|
||||
* rTorrent is the most awkward of the supported clients to drive from a
|
||||
* test runner because:
|
||||
* - It uses XML-RPC (not JSON), so we hand-build the request/response
|
||||
* - There is no native "skip hash check" — we use `load.raw` (load only,
|
||||
* no auto-start) so rTorrent never tries to peer or verify pieces.
|
||||
*
|
||||
* If the spec for this client fails because of XML escaping or because the
|
||||
* rutorrent nginx isn't routing /RPC2, this file is the most likely place
|
||||
* to need adjustment.
|
||||
*/
|
||||
export class RTorrentDriver implements TorrentClientDriver {
|
||||
readonly typeName = 'rTorrent' as const;
|
||||
readonly cleanuparrHost: string;
|
||||
readonly username?: string;
|
||||
readonly password?: string;
|
||||
private readonly directRpc: string;
|
||||
|
||||
constructor(host = 'http://localhost:8088/RPC2') {
|
||||
this.cleanuparrHost = host;
|
||||
this.directRpc = host;
|
||||
}
|
||||
|
||||
async ready(): Promise<void> {
|
||||
await pollUntilOk(
|
||||
async () => {
|
||||
try {
|
||||
await this.call('system.client_version', []);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ label: 'rTorrent XML-RPC' },
|
||||
);
|
||||
}
|
||||
|
||||
private async call(method: string, params: Array<XmlRpcValue>): Promise<any> {
|
||||
const xml = buildXmlRpcRequest(method, params);
|
||||
const res = await fetch(this.directRpc, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/xml' },
|
||||
body: xml,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`rTorrent ${method} failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
const text = await res.text();
|
||||
return parseXmlRpcResponse(text);
|
||||
}
|
||||
|
||||
async addTorrent({ metainfo, savePath, name, infoHash }: { metainfo: Buffer; savePath: string; name: string; infoHash: string }): Promise<void> {
|
||||
// load.raw_start_verbose loads AND starts the torrent. Starting triggers
|
||||
// an immediate hash check, which (for our tiny 32KB files matching the
|
||||
// metainfo piece hashes) populates `d.base_path` — the field Cleanuparr
|
||||
// reads as the torrent's save path. Without starting, `d.base_path`
|
||||
// stays empty and Cleanuparr can't build a claimed-paths set.
|
||||
await this.call('load.raw_start_verbose', [
|
||||
'',
|
||||
{ type: 'base64', value: metainfo.toString('base64') },
|
||||
`d.directory.set="${savePath}"`,
|
||||
`d.custom1.set="${name}"`,
|
||||
]);
|
||||
void infoHash;
|
||||
}
|
||||
|
||||
async deleteTorrent(infoHash: string): Promise<void> {
|
||||
// d.erase removes the torrent from rTorrent's session without touching
|
||||
// the data on disk.
|
||||
await this.call('d.erase', [infoHash.toUpperCase()]);
|
||||
}
|
||||
|
||||
async clearAllTorrents(): Promise<void> {
|
||||
const all = await this.listTorrents();
|
||||
for (const t of all) {
|
||||
try {
|
||||
await this.call('d.erase', [t.hash.toUpperCase()]);
|
||||
} catch {
|
||||
// best-effort: continue clearing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listTorrents(): Promise<Array<{ hash: string; name: string }>> {
|
||||
const result = await this.call('d.multicall2', ['', 'main', 'd.hash=', 'd.name=']);
|
||||
if (!Array.isArray(result)) return [];
|
||||
return result.map((row: unknown) => {
|
||||
const arr = row as unknown[];
|
||||
return { hash: String(arr[0]).toLowerCase(), name: String(arr[1]) };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type XmlRpcValue = string | number | boolean | { type: 'base64'; value: string };
|
||||
|
||||
function buildXmlRpcRequest(method: string, params: XmlRpcValue[]): string {
|
||||
const paramsXml = params.map((p) => `<param>${encodeValue(p)}</param>`).join('');
|
||||
return `<?xml version="1.0"?><methodCall><methodName>${escapeXml(method)}</methodName><params>${paramsXml}</params></methodCall>`;
|
||||
}
|
||||
|
||||
function encodeValue(v: XmlRpcValue): string {
|
||||
if (typeof v === 'number') {
|
||||
return Number.isInteger(v) ? `<value><int>${v}</int></value>` : `<value><double>${v}</double></value>`;
|
||||
}
|
||||
if (typeof v === 'boolean') {
|
||||
return `<value><boolean>${v ? 1 : 0}</boolean></value>`;
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
return `<value><string>${escapeXml(v)}</string></value>`;
|
||||
}
|
||||
if (v && typeof v === 'object' && v.type === 'base64') {
|
||||
return `<value><base64>${v.value}</base64></value>`;
|
||||
}
|
||||
throw new Error(`xml-rpc: unsupported value ${typeof v}`);
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s.replace(/[<>&'"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[c]!));
|
||||
}
|
||||
|
||||
function parseXmlRpcResponse(xml: string): unknown {
|
||||
if (xml.includes('<fault>')) {
|
||||
const msg = xml.match(/<name>faultString<\/name>\s*<value><string>([^<]+)<\/string>/)?.[1] ?? xml;
|
||||
throw new Error(`xml-rpc fault: ${msg}`);
|
||||
}
|
||||
const paramsMatch = xml.match(/<params>([\s\S]*?)<\/params>/);
|
||||
if (!paramsMatch) return null;
|
||||
return parseValue(paramsMatch[1]);
|
||||
}
|
||||
|
||||
function parseValue(xml: string): unknown {
|
||||
// Very small subset of XML-RPC parsing: handles int/string/boolean/array/struct/base64.
|
||||
const tag = xml.match(/<value>\s*<([a-zA-Z0-9]+)>/);
|
||||
if (!tag) {
|
||||
// Bare <value>text</value> is treated as string per spec.
|
||||
const bare = xml.match(/<value>([\s\S]*?)<\/value>/);
|
||||
return bare ? bare[1].trim() : null;
|
||||
}
|
||||
const type = tag[1];
|
||||
if (type === 'array') {
|
||||
// Greedy — for nested arrays, we want the OUTER </data> not the first inner one.
|
||||
const inner = xml.match(/<array>\s*<data>([\s\S]*)<\/data>\s*<\/array>/)?.[1] ?? '';
|
||||
return splitValues(inner).map(parseValue);
|
||||
}
|
||||
if (type === 'struct') {
|
||||
const inner = xml.match(/<struct>([\s\S]*)<\/struct>/)?.[1] ?? '';
|
||||
const out: Record<string, unknown> = {};
|
||||
const memberRe = /<member>\s*<name>([^<]+)<\/name>\s*([\s\S]*?)<\/member>/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = memberRe.exec(inner)) !== null) {
|
||||
out[m[1]] = parseValue(m[2]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const scalar = xml.match(new RegExp(`<${type}>([\\s\\S]*?)<\\/${type}>`))?.[1] ?? '';
|
||||
if (type === 'int' || type === 'i4') return Number(scalar);
|
||||
if (type === 'boolean') return scalar === '1';
|
||||
if (type === 'double') return Number(scalar);
|
||||
return decodeXml(scalar);
|
||||
}
|
||||
|
||||
function splitValues(xml: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let start = -1;
|
||||
for (let i = 0; i < xml.length; i++) {
|
||||
if (xml.startsWith('<value>', i)) {
|
||||
if (depth === 0) start = i;
|
||||
depth++;
|
||||
i += '<value>'.length - 1;
|
||||
} else if (xml.startsWith('</value>', i)) {
|
||||
depth--;
|
||||
if (depth === 0 && start !== -1) {
|
||||
out.push(xml.slice(start, i + '</value>'.length));
|
||||
start = -1;
|
||||
}
|
||||
i += '</value>'.length - 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function decodeXml(s: string): string {
|
||||
return s.replace(/&(lt|gt|amp|apos|quot);/g, (_, e) => ({ lt: '<', gt: '>', amp: '&', apos: "'", quot: '"' }[e as 'lt']!));
|
||||
}
|
||||
107
e2e/tests/helpers/torrent-clients/transmission.ts
Normal file
107
e2e/tests/helpers/torrent-clients/transmission.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { TorrentClientDriver, pollUntilOk } from './types';
|
||||
|
||||
/**
|
||||
* Transmission driver (transmission-rpc protocol).
|
||||
*
|
||||
* Transmission requires a CSRF-style session id obtained by issuing any RPC
|
||||
* call and reading the `X-Transmission-Session-Id` header from the 409
|
||||
* response, then replaying with that header. We refresh the id transparently
|
||||
* on each request.
|
||||
*
|
||||
* Compose wires linuxserver/transmission with USER=transmission /
|
||||
* PASS=transmission, which gates the RPC endpoint behind basic auth.
|
||||
*/
|
||||
export class TransmissionDriver implements TorrentClientDriver {
|
||||
readonly typeName = 'Transmission' as const;
|
||||
readonly cleanuparrHost: string;
|
||||
readonly username: string;
|
||||
readonly password: string;
|
||||
private readonly directRpc: string;
|
||||
private sessionId = '';
|
||||
|
||||
constructor(host = 'http://localhost:9091/transmission', username = 'transmission', password = 'transmission') {
|
||||
this.cleanuparrHost = host;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.directRpc = `${host.replace(/\/$/, '')}/rpc`;
|
||||
}
|
||||
|
||||
async ready(): Promise<void> {
|
||||
await pollUntilOk(
|
||||
async () => {
|
||||
try {
|
||||
await this.call('session-get', {});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ label: 'Transmission RPC' },
|
||||
);
|
||||
}
|
||||
|
||||
private authHeader(): string {
|
||||
return 'Basic ' + Buffer.from(`${this.username}:${this.password}`).toString('base64');
|
||||
}
|
||||
|
||||
private async call(method: string, args: Record<string, unknown>): Promise<any> {
|
||||
const send = async () => {
|
||||
return fetch(this.directRpc, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': this.authHeader(),
|
||||
'X-Transmission-Session-Id': this.sessionId,
|
||||
},
|
||||
body: JSON.stringify({ method, arguments: args }),
|
||||
});
|
||||
};
|
||||
let res = await send();
|
||||
if (res.status === 409) {
|
||||
this.sessionId = res.headers.get('x-transmission-session-id') ?? '';
|
||||
res = await send();
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`Transmission ${method} failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
if (body.result !== 'success') {
|
||||
throw new Error(`Transmission ${method} non-success: ${body.result}`);
|
||||
}
|
||||
return body.arguments;
|
||||
}
|
||||
|
||||
async addTorrent({ metainfo, savePath, infoHash }: { metainfo: Buffer; savePath: string; name: string; infoHash: string }): Promise<void> {
|
||||
await this.call('torrent-add', {
|
||||
metainfo: metainfo.toString('base64'),
|
||||
'download-dir': savePath,
|
||||
paused: true,
|
||||
});
|
||||
// The torrent is added in a paused, unverified state. Transmission will
|
||||
// try to verify on resume — we never resume, so it stays in stopped/
|
||||
// queued state with savePath populated, which is enough for the cleaner
|
||||
// to pick it up via GetAllTorrentsLite.
|
||||
void infoHash;
|
||||
}
|
||||
|
||||
async deleteTorrent(infoHash: string): Promise<void> {
|
||||
await this.call('torrent-remove', {
|
||||
ids: [infoHash],
|
||||
'delete-local-data': false,
|
||||
});
|
||||
}
|
||||
|
||||
async clearAllTorrents(): Promise<void> {
|
||||
const all = await this.listTorrents();
|
||||
if (all.length === 0) return;
|
||||
await this.call('torrent-remove', {
|
||||
ids: all.map((t) => t.hash),
|
||||
'delete-local-data': false,
|
||||
});
|
||||
}
|
||||
|
||||
async listTorrents(): Promise<Array<{ hash: string; name: string }>> {
|
||||
const args = await this.call('torrent-get', { fields: ['hashString', 'name'] });
|
||||
return (args.torrents ?? []).map((t: { hashString: string; name: string }) => ({ hash: t.hashString, name: t.name }));
|
||||
}
|
||||
}
|
||||
61
e2e/tests/helpers/torrent-clients/types.ts
Normal file
61
e2e/tests/helpers/torrent-clients/types.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export type TorrentClientType = 'qBittorrent' | 'Transmission' | 'Deluge' | 'uTorrent' | 'rTorrent';
|
||||
|
||||
/**
|
||||
* Minimal driver surface used by the orphaned-files spec. Each implementation
|
||||
* wraps a specific torrent client's HTTP API and exposes:
|
||||
*
|
||||
* - `ready()` — block until the client is accepting requests
|
||||
* - `addTorrent({ metainfo, savePath, name })` — register a torrent whose
|
||||
* data already exists on disk (no actual downloading)
|
||||
* - `deleteTorrent(hash, { deleteFiles })` — remove a torrent from the
|
||||
* client; the spec always passes deleteFiles=false to leave the orphan
|
||||
* on disk so the cleaner has something to detect
|
||||
* - `listTorrents()` — used to assert state after operations
|
||||
*
|
||||
* `host` is the URL the *Cleanuparr backend* should be configured with — not
|
||||
* necessarily the URL the test helper itself talks to (some clients require
|
||||
* a different sub-path for their RPC endpoint).
|
||||
*/
|
||||
export interface TorrentClientDriver {
|
||||
readonly typeName: TorrentClientType;
|
||||
/** Hostname+path the Cleanuparr backend uses to reach this client. */
|
||||
readonly cleanuparrHost: string;
|
||||
readonly username?: string;
|
||||
readonly password?: string;
|
||||
|
||||
ready(): Promise<void>;
|
||||
addTorrent(input: { metainfo: Buffer; savePath: string; name: string; infoHash: string }): Promise<void>;
|
||||
deleteTorrent(infoHash: string): Promise<void>;
|
||||
listTorrents(): Promise<Array<{ hash: string; name: string }>>;
|
||||
/**
|
||||
* Remove every torrent currently registered with the client without deleting
|
||||
* data on disk. Called at the start of each test to make the spec
|
||||
* idempotent across re-runs (the torrent client's state persists in its
|
||||
* config volume between `make test` invocations).
|
||||
*/
|
||||
clearAllTorrents(): Promise<void>;
|
||||
}
|
||||
|
||||
export class ClientNotImplementedError extends Error {
|
||||
constructor(client: TorrentClientType, detail: string) {
|
||||
super(`${client}: ${detail}`);
|
||||
this.name = 'ClientNotImplementedError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollUntilOk(
|
||||
fn: () => Promise<boolean>,
|
||||
{ timeoutMs = 90_000, intervalMs = 1500, label = 'condition' }: { timeoutMs?: number; intervalMs?: number; label?: string } = {},
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
let lastError: unknown;
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
if (await fn()) return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${label} after ${timeoutMs}ms (last error: ${String(lastError)})`);
|
||||
}
|
||||
116
e2e/tests/helpers/torrent-clients/utorrent.ts
Normal file
116
e2e/tests/helpers/torrent-clients/utorrent.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { TorrentClientDriver, pollUntilOk } from './types';
|
||||
|
||||
/**
|
||||
* µTorrent driver (WebUI HTTP API).
|
||||
*
|
||||
* The legacy uTorrent Server for Linux is reanimated by the `ekho/utorrent`
|
||||
* Docker image. Auth is HTTP Basic; the WebUI also requires a CSRF token
|
||||
* fetched from /gui/token.html plus a `GUID` cookie set by that same call.
|
||||
*
|
||||
* The list endpoint returns a JSON object whose `torrents` field is an array
|
||||
* of arrays — each row is `[hash, status, name, size, ...]`.
|
||||
*/
|
||||
export class UTorrentDriver implements TorrentClientDriver {
|
||||
readonly typeName = 'uTorrent' as const;
|
||||
readonly cleanuparrHost: string;
|
||||
readonly username: string;
|
||||
readonly password: string;
|
||||
private readonly directHost: string;
|
||||
private token = '';
|
||||
private cookie = '';
|
||||
|
||||
constructor(host = 'http://localhost:8083', username = 'admin', password = '') {
|
||||
this.cleanuparrHost = host;
|
||||
this.directHost = host;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
private authHeader(): string {
|
||||
return 'Basic ' + Buffer.from(`${this.username}:${this.password}`).toString('base64');
|
||||
}
|
||||
|
||||
private requestHeaders(): Record<string, string> {
|
||||
const h: Record<string, string> = { Authorization: this.authHeader() };
|
||||
if (this.cookie) h.Cookie = this.cookie;
|
||||
return h;
|
||||
}
|
||||
|
||||
async ready(): Promise<void> {
|
||||
await pollUntilOk(
|
||||
async () => {
|
||||
try {
|
||||
await this.refreshToken();
|
||||
return this.token !== '';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ label: 'uTorrent WebUI' },
|
||||
);
|
||||
}
|
||||
|
||||
private async refreshToken(): Promise<void> {
|
||||
const res = await fetch(`${this.directHost}/gui/token.html`, {
|
||||
headers: { Authorization: this.authHeader() },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`uTorrent token: ${res.status}`);
|
||||
}
|
||||
const text = await res.text();
|
||||
const match = text.match(/<div[^>]*id=['"]token['"][^>]*>([^<]+)<\/div>/);
|
||||
if (!match) {
|
||||
throw new Error(`uTorrent token not found in response body: ${text.slice(0, 200)}`);
|
||||
}
|
||||
this.token = match[1];
|
||||
const setCookie = res.headers.get('set-cookie');
|
||||
if (setCookie) {
|
||||
this.cookie = setCookie.split(';')[0];
|
||||
}
|
||||
}
|
||||
|
||||
async addTorrent({ metainfo, name }: { metainfo: Buffer; savePath: string; name: string; infoHash: string }): Promise<void> {
|
||||
const form = new FormData();
|
||||
form.append('torrent_file', new Blob([new Uint8Array(metainfo)]), `${name}.torrent`);
|
||||
const url = `${this.directHost}/gui/?token=${encodeURIComponent(this.token)}&action=add-file`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: this.requestHeaders(),
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`uTorrent add: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTorrent(infoHash: string): Promise<void> {
|
||||
// `remove` removes the torrent from the client without touching files;
|
||||
// `removedata` / `removedatatorrent` delete data and torrent file.
|
||||
const url = `${this.directHost}/gui/?token=${encodeURIComponent(this.token)}&action=remove&hash=${infoHash.toUpperCase()}`;
|
||||
const res = await fetch(url, { headers: this.requestHeaders() });
|
||||
if (!res.ok) {
|
||||
throw new Error(`uTorrent remove: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async listTorrents(): Promise<Array<{ hash: string; name: string }>> {
|
||||
const url = `${this.directHost}/gui/?token=${encodeURIComponent(this.token)}&list=1`;
|
||||
const res = await fetch(url, { headers: this.requestHeaders() });
|
||||
if (!res.ok) {
|
||||
throw new Error(`uTorrent list: ${res.status}`);
|
||||
}
|
||||
const body: { torrents?: unknown[][] } = await res.json();
|
||||
return (body.torrents ?? []).map((row) => ({
|
||||
hash: String(row[0]).toLowerCase(),
|
||||
name: String(row[2]),
|
||||
}));
|
||||
}
|
||||
|
||||
async clearAllTorrents(): Promise<void> {
|
||||
const all = await this.listTorrents();
|
||||
for (const t of all) {
|
||||
const url = `${this.directHost}/gui/?token=${encodeURIComponent(this.token)}&action=remove&hash=${t.hash.toUpperCase()}`;
|
||||
await fetch(url, { headers: this.requestHeaders() });
|
||||
}
|
||||
}
|
||||
}
|
||||
148
e2e/tests/helpers/torrent-fixtures.ts
Normal file
148
e2e/tests/helpers/torrent-fixtures.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { chmodSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Bencode an arbitrary value. Supports integers, Buffers, strings (utf-8),
|
||||
* arrays, and plain objects (whose keys are sorted lexicographically as
|
||||
* required by BEP-3).
|
||||
*/
|
||||
function bencode(value: unknown): Buffer {
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isInteger(value)) {
|
||||
throw new Error(`bencode: non-integer number ${value}`);
|
||||
}
|
||||
return Buffer.from(`i${value}e`);
|
||||
}
|
||||
if (Buffer.isBuffer(value)) {
|
||||
return Buffer.concat([Buffer.from(`${value.length}:`), value]);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const buf = Buffer.from(value, 'utf8');
|
||||
return Buffer.concat([Buffer.from(`${buf.length}:`), buf]);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return Buffer.concat([Buffer.from('l'), ...value.map(bencode), Buffer.from('e')]);
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
const parts: Buffer[] = [Buffer.from('d')];
|
||||
for (const k of keys) {
|
||||
parts.push(bencode(k));
|
||||
parts.push(bencode(obj[k]));
|
||||
}
|
||||
parts.push(Buffer.from('e'));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
throw new Error(`bencode: unsupported value ${typeof value}`);
|
||||
}
|
||||
|
||||
export interface GeneratedTorrent {
|
||||
/** Bencoded .torrent metainfo buffer */
|
||||
metainfo: Buffer;
|
||||
/** Lowercase hex SHA-1 of the bencoded info dict — the torrent's infohash */
|
||||
infoHash: string;
|
||||
/** Name of the top-level directory inside the torrent */
|
||||
name: string;
|
||||
/** Absolute on-disk path to the directory containing the torrent's data */
|
||||
contentPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single-file multi-piece torrent on disk and return its metainfo.
|
||||
*
|
||||
* The data file is written to `<savePath>/<name>/data.bin` and contains
|
||||
* deterministic random bytes seeded from `name` so re-runs produce the same
|
||||
* content (and thus the same infohash) for a given name.
|
||||
*
|
||||
* @param savePath - directory where the torrent's top-level folder will be created
|
||||
* @param name - top-level folder name (also the torrent's `info.name`)
|
||||
* @param sizeBytes - total size of the inner data file
|
||||
*/
|
||||
export function buildFolderTorrent(savePath: string, name: string, sizeBytes = 32_768): GeneratedTorrent {
|
||||
const contentPath = join(savePath, name);
|
||||
mkdirSync(contentPath, { recursive: true });
|
||||
chmodIgnoringEPERM(contentPath, 0o777);
|
||||
|
||||
// Deterministic content: HMAC-like expansion from the name so two runs
|
||||
// produce identical bytes (and thus identical pieces / infohash).
|
||||
const seed = createHash('sha256').update(`cleanuparr-e2e:${name}`).digest();
|
||||
const data = Buffer.alloc(sizeBytes);
|
||||
let offset = 0;
|
||||
let counter = 0;
|
||||
while (offset < sizeBytes) {
|
||||
const block = createHash('sha256').update(seed).update(Buffer.from([counter & 0xff, (counter >> 8) & 0xff])).digest();
|
||||
block.copy(data, offset, 0, Math.min(block.length, sizeBytes - offset));
|
||||
offset += block.length;
|
||||
counter++;
|
||||
}
|
||||
writeFileSync(join(contentPath, 'data.bin'), data);
|
||||
|
||||
const pieceLength = 16384;
|
||||
const pieces: Buffer[] = [];
|
||||
for (let i = 0; i < data.length; i += pieceLength) {
|
||||
const piece = data.subarray(i, Math.min(i + pieceLength, data.length));
|
||||
pieces.push(createHash('sha1').update(piece).digest());
|
||||
}
|
||||
const piecesConcat = Buffer.concat(pieces);
|
||||
|
||||
const info = {
|
||||
name,
|
||||
'piece length': pieceLength,
|
||||
pieces: piecesConcat,
|
||||
files: [
|
||||
{ length: data.length, path: ['data.bin'] },
|
||||
],
|
||||
// Mark as private to short-circuit DHT/PEX work in clients.
|
||||
private: 1,
|
||||
};
|
||||
const metainfo = bencode({
|
||||
announce: 'http://tracker.invalid/announce',
|
||||
'created by': 'cleanuparr-e2e',
|
||||
'creation date': 0,
|
||||
info,
|
||||
});
|
||||
const infoHash = createHash('sha1').update(bencode(info)).digest('hex');
|
||||
|
||||
return { metainfo, infoHash, name, contentPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* `chmodSync` that tolerates EPERM. The torrent-client bind mounts
|
||||
* (`test-data/downloads/<client>`) are chowned to PUID=1000 by
|
||||
* linuxserver.io entrypoints, while CI's Playwright runner is uid 1001
|
||||
* and cannot chmod paths it doesn't own. Mode bits are already 0o777
|
||||
* from setup-test-data.sh's `chmod -R a+rwX`, so the chmod is best-effort.
|
||||
*/
|
||||
export function chmodIgnoringEPERM(path: string, mode: number): void {
|
||||
try {
|
||||
chmodSync(path, mode);
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code !== 'EPERM') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe and recreate a directory. Used at test setup to reset client data.
|
||||
*/
|
||||
export function resetDirectory(path: string): void {
|
||||
mkdirSync(path, { recursive: true });
|
||||
for (const entry of readdirSync(path)) {
|
||||
rmSync(join(path, entry), { recursive: true, force: true });
|
||||
}
|
||||
chmodIgnoringEPERM(path, 0o777);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a random extra file directly under a directory. Useful to seed an
|
||||
* unrelated file that the cleaner should classify as orphaned.
|
||||
*/
|
||||
export function writeRandomFile(dir: string, name: string, sizeBytes = 1024): string {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const path = join(dir, name);
|
||||
writeFileSync(path, randomBytes(sizeBytes));
|
||||
return path;
|
||||
}
|
||||
Reference in New Issue
Block a user