Add option to keep source files when cleaning downloads (#388)

This commit is contained in:
Flaminel
2025-12-19 23:52:59 +02:00
committed by GitHub
parent c07b811cf8
commit 4ceff127a7
39 changed files with 2675 additions and 256 deletions

View File

@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
public record SeedingRuleRequest
{
[Required]
public string Name { get; init; } = string.Empty;
/// <summary>
/// Max ratio before removing a download.
/// </summary>
public double MaxRatio { get; init; } = -1;
/// <summary>
/// Min number of hours to seed before removing a download, if the ratio has been met.
/// </summary>
public double MinSeedTime { get; init; }
/// <summary>
/// Number of hours to seed before removing a download.
/// </summary>
public double MaxSeedTime { get; init; } = -1;
/// <summary>
/// Whether to delete the source files when cleaning the download.
/// </summary>
public bool DeleteSourceFiles { get; init; } = true;
}

View File

@@ -1,8 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
public record UpdateDownloadCleanerConfigRequest
public sealed record UpdateDownloadCleanerConfigRequest
{
public bool Enabled { get; init; }
@@ -13,7 +11,7 @@ public record UpdateDownloadCleanerConfigRequest
/// </summary>
public bool UseAdvancedScheduling { get; init; }
public List<CleanCategoryRequest> Categories { get; init; } = [];
public List<SeedingRuleRequest> Categories { get; init; } = [];
public bool DeletePrivate { get; init; }
@@ -32,24 +30,3 @@ public record UpdateDownloadCleanerConfigRequest
public List<string> IgnoredDownloads { get; init; } = [];
}
public record CleanCategoryRequest
{
[Required]
public string Name { get; init; } = string.Empty;
/// <summary>
/// Max ratio before removing a download.
/// </summary>
public double MaxRatio { get; init; } = -1;
/// <summary>
/// Min number of hours to seed before removing a download, if the ratio has been met.
/// </summary>
public double MinSeedTime { get; init; }
/// <summary>
/// Number of hours to seed before removing a download.
/// </summary>
public double MaxSeedTime { get; init; } = -1;
}

View File

@@ -85,17 +85,18 @@ public sealed class DownloadCleanerConfigController : ControllerBase
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
oldConfig.Categories.Clear();
_dataContext.CleanCategories.RemoveRange(oldConfig.Categories);
_dataContext.SeedingRules.RemoveRange(oldConfig.Categories);
_dataContext.DownloadCleanerConfigs.Update(oldConfig);
foreach (var categoryDto in newConfigDto.Categories)
{
_dataContext.CleanCategories.Add(new CleanCategory
_dataContext.SeedingRules.Add(new SeedingRule
{
Name = categoryDto.Name,
MaxRatio = categoryDto.MaxRatio,
MinSeedTime = categoryDto.MinSeedTime,
MaxSeedTime = categoryDto.MaxSeedTime,
DeleteSourceFiles = categoryDto.DeleteSourceFiles,
DownloadCleanerConfigId = oldConfig.Id
});
}

View File

@@ -1,23 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
namespace Cleanuparr.Api.Models;
/// <summary>
/// Legacy namespace shim; prefer <see cref="UpdateDownloadCleanerConfigRequest"/> from
/// <c>Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests</c>.
/// </summary>
[Obsolete("Use Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests.UpdateDownloadCleanerConfigRequest instead")]
[SuppressMessage("Design", "CA1000", Justification = "Temporary alias during refactor")]
[SuppressMessage("Usage", "CA2225", Justification = "Alias type")]
public record UpdateDownloadCleanerConfigDto : UpdateDownloadCleanerConfigRequest;
/// <summary>
/// Legacy namespace shim; prefer <see cref="CleanCategoryRequest"/> from
/// <c>Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests</c>.
/// </summary>
[Obsolete("Use Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests.CleanCategoryRequest instead")]
[SuppressMessage("Design", "CA1000", Justification = "Temporary alias during refactor")]
[SuppressMessage("Usage", "CA2225", Justification = "Alias type")]
public record CleanCategoryDto : CleanCategoryRequest;

View File

@@ -133,10 +133,10 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
new DelugeItemWrapper(new DownloadStatus { Hash = "hash3", Label = "music", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
var categories = new List<CleanCategory>
var categories = new List<SeedingRule>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 },
new CleanCategory { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -160,9 +160,9 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "Movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
var categories = new List<CleanCategory>
var categories = new List<SeedingRule>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -184,9 +184,9 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Label = "music", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
var categories = new List<CleanCategory>
var categories = new List<SeedingRule>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -342,15 +342,15 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
const string hash = "TEST-HASH";
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash"))))
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash);
await sut.DeleteDownload(hash, true);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash"))),
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), true),
Times.Once);
}
@@ -362,15 +362,35 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
const string hash = "UPPERCASE-HASH";
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrents(It.IsAny<List<string>>()))
.Setup(x => x.DeleteTorrents(It.IsAny<List<string>>(), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash);
await sut.DeleteDownload(hash, true);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("uppercase-hash"))),
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("uppercase-hash")), true),
Times.Once);
}
[Fact]
public async Task CallsClientDeleteWithoutSourceFiles()
{
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), false))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, false);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), false),
Times.Once);
}
}

View File

@@ -214,10 +214,10 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
new QBitItemWrapper(new TorrentInfo { Hash = "hash3", Category = "music" }, Array.Empty<TorrentTracker>(), false)
};
var categories = new List<CleanCategory>
var categories = new List<SeedingRule>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 },
new CleanCategory { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -240,9 +240,9 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "Movies" }, Array.Empty<TorrentTracker>(), false)
};
var categories = new List<CleanCategory>
var categories = new List<SeedingRule>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -264,9 +264,9 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "movies" }, Array.Empty<TorrentTracker>(), false)
};
var categories = new List<CleanCategory>
var categories = new List<SeedingRule>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -288,9 +288,9 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
new QBitItemWrapper(new TorrentInfo { Hash = "hash1", Category = "music" }, Array.Empty<TorrentTracker>(), false)
};
var categories = new List<CleanCategory>
var categories = new List<SeedingRule>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -509,7 +509,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash);
await sut.DeleteDownload(hash, true);
// Assert
_fixture.ClientWrapper.Verify(
@@ -529,7 +529,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash);
await sut.DeleteDownload(hash, true);
// Assert
_fixture.ClientWrapper.Verify(

View File

@@ -138,10 +138,10 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash3", DownloadDir = "/downloads/music" })
};
var categories = new List<CleanCategory>
var categories = new List<SeedingRule>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 },
new CleanCategory { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -165,9 +165,9 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/Movies" })
};
var categories = new List<CleanCategory>
var categories = new List<SeedingRule>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -189,9 +189,9 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/music" })
};
var categories = new List<CleanCategory>
var categories = new List<SeedingRule>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -340,7 +340,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash);
await sut.DeleteDownload(hash, true);
// Assert
_fixture.ClientWrapper.Verify(
@@ -379,7 +379,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
.ReturnsAsync((TransmissionTorrents?)null);
// Act
await sut.DeleteDownload(hash);
await sut.DeleteDownload(hash, true);
// Assert - no exception thrown
_fixture.ClientWrapper.Verify(
@@ -426,7 +426,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
.ReturnsAsync(torrents);
// Act
await sut.DeleteDownload(hash);
await sut.DeleteDownload(hash, true);
// Assert
_fixture.ClientWrapper.Verify(

View File

@@ -126,10 +126,10 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash3", Label = "music" }, new UTorrentProperties { Hash = "hash3", Pex = 1, Trackers = "" })
};
var categories = new List<CleanCategory>
var categories = new List<SeedingRule>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 },
new CleanCategory { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -153,9 +153,9 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "Movies" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
var categories = new List<CleanCategory>
var categories = new List<SeedingRule>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -177,9 +177,9 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
new UTorrentItemWrapper(new UTorrentItem { Hash = "hash1", Label = "music" }, new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" })
};
var categories = new List<CleanCategory>
var categories = new List<SeedingRule>
{
new CleanCategory { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1 }
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
@@ -292,15 +292,15 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
const string hash = "TEST-HASH";
_fixture.ClientWrapper
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash"))))
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash);
await sut.DeleteDownload(hash, true);
// Assert
_fixture.ClientWrapper.Verify(
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash"))),
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), true),
Times.Once);
}
@@ -312,15 +312,35 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
const string hash = "UPPERCASE-HASH";
_fixture.ClientWrapper
.Setup(x => x.RemoveTorrentsAsync(It.IsAny<List<string>>()))
.Setup(x => x.RemoveTorrentsAsync(It.IsAny<List<string>>(), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash);
await sut.DeleteDownload(hash, true);
// Assert
_fixture.ClientWrapper.Verify(
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("uppercase-hash"))),
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("uppercase-hash")), true),
Times.Once);
}
[Fact]
public async Task CallsClientDeleteWithoutSourceFiles()
{
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
_fixture.ClientWrapper
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), false))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, false);
// Assert
_fixture.ClientWrapper.Verify(
x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), false),
Times.Once);
}
}

View File

@@ -151,7 +151,7 @@ public class DownloadCleanerTests : IDisposable
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddCleanCategory(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
@@ -185,7 +185,7 @@ public class DownloadCleanerTests : IDisposable
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddCleanCategory(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
// Add ignored download to general config
var generalConfig = _fixture.DataContext.GeneralConfigs.First();
@@ -229,7 +229,7 @@ public class DownloadCleanerTests : IDisposable
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddCleanCategory(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
@@ -294,7 +294,7 @@ public class DownloadCleanerTests : IDisposable
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddCleanCategory(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
@@ -312,7 +312,7 @@ public class DownloadCleanerTests : IDisposable
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<CleanCategory>>()
It.IsAny<List<SeedingRule>>()
))
.Returns([]);
@@ -419,7 +419,7 @@ public class DownloadCleanerTests : IDisposable
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddCleanCategory(_fixture.DataContext, "completed", 1.0, 60);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext, "completed", 1.0, 60);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
@@ -434,13 +434,13 @@ public class DownloadCleanerTests : IDisposable
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<CleanCategory>>()
It.IsAny<List<SeedingRule>>()
))
.Returns([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.CleanDownloadsAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<CleanCategory>>()
It.IsAny<List<SeedingRule>>()
))
.Returns(Task.CompletedTask);
@@ -475,7 +475,7 @@ public class DownloadCleanerTests : IDisposable
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddCleanCategory(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
// Need at least one download for arr processing to occur
@@ -492,7 +492,7 @@ public class DownloadCleanerTests : IDisposable
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<CleanCategory>>()
It.IsAny<List<SeedingRule>>()
))
.Returns([]);
@@ -548,7 +548,7 @@ public class DownloadCleanerTests : IDisposable
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext, "Failing Client");
TestDataContextFactory.AddDownloadClient(_fixture.DataContext, "Working Client");
TestDataContextFactory.AddCleanCategory(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
var failingService = _fixture.CreateMockDownloadService("Failing Client");
failingService
@@ -754,7 +754,7 @@ public class DownloadCleanerTests : IDisposable
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddCleanCategory(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
@@ -769,7 +769,7 @@ public class DownloadCleanerTests : IDisposable
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<CleanCategory>>()
It.IsAny<List<SeedingRule>>()
))
.Throws(new Exception("Filter failed"));
@@ -800,7 +800,7 @@ public class DownloadCleanerTests : IDisposable
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddCleanCategory(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns("test-hash");
@@ -815,13 +815,13 @@ public class DownloadCleanerTests : IDisposable
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<CleanCategory>>()
It.IsAny<List<SeedingRule>>()
))
.Returns([mockTorrent.Object]);
mockDownloadService
.Setup(x => x.CleanDownloadsAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<CleanCategory>>()
It.IsAny<List<SeedingRule>>()
))
.ThrowsAsync(new Exception("Clean failed"));
@@ -852,7 +852,7 @@ public class DownloadCleanerTests : IDisposable
{
// Arrange - DownloadCleaner calls ProcessArrConfigAsync with throwOnFailure=true
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
TestDataContextFactory.AddCleanCategory(_fixture.DataContext);
TestDataContextFactory.AddSeedingRule(_fixture.DataContext);
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockTorrent = new Mock<ITorrentItemWrapper>();
@@ -868,7 +868,7 @@ public class DownloadCleanerTests : IDisposable
mockDownloadService
.Setup(x => x.FilterDownloadsToBeCleanedAsync(
It.IsAny<List<ITorrentItemWrapper>>(),
It.IsAny<List<CleanCategory>>()
It.IsAny<List<SeedingRule>>()
))
.Returns([]);

View File

@@ -308,7 +308,7 @@ public static class TestDataContextFactory
/// <summary>
/// Adds a clean category to the download cleaner config
/// </summary>
public static CleanCategory AddCleanCategory(
public static SeedingRule AddSeedingRule(
DataContext context,
string name = "completed",
double maxRatio = 1.0,
@@ -316,18 +316,19 @@ public static class TestDataContextFactory
double maxSeedTime = -1)
{
var config = context.DownloadCleanerConfigs.Include(x => x.Categories).First();
var category = new CleanCategory
var category = new SeedingRule
{
Id = Guid.NewGuid(),
Name = name,
MaxRatio = maxRatio,
MinSeedTime = minSeedTime,
MaxSeedTime = maxSeedTime,
DeleteSourceFiles = true,
DownloadCleanerConfigId = config.Id
};
config.Categories.Add(category);
context.CleanCategories.Add(category);
context.SeedingRules.Add(category);
context.SaveChanges();
return category;

View File

@@ -156,9 +156,9 @@ public sealed class DelugeClient
await SendRequest<DelugeResponse<object>>("core.set_torrent_options", hash, filePriorities);
}
public async Task DeleteTorrents(List<string> hashes)
public async Task DeleteTorrents(List<string> hashes, bool removeData)
{
await SendRequest<DelugeResponse<object>>("core.remove_torrents", hashes, true);
await SendRequest<DelugeResponse<object>>("core.remove_torrents", hashes, removeData);
}
private async Task<String> PostJson(String json)

View File

@@ -35,8 +35,8 @@ public sealed class DelugeClientWrapper : IDelugeClientWrapper
public Task<List<DownloadStatus>?> GetStatusForAllTorrents()
=> _client.GetStatusForAllTorrents();
public Task DeleteTorrents(List<string> hashes)
=> _client.DeleteTorrents(hashes);
public Task DeleteTorrents(List<string> hashes, bool removeData)
=> _client.DeleteTorrents(hashes, removeData);
public Task ChangeFilesPriority(string hash, List<int> priorities)
=> _client.ChangeFilesPriority(hash, priorities);

View File

@@ -25,9 +25,9 @@ public partial class DelugeService
.ToList();
}
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<CleanCategory> categories) =>
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules) =>
downloads
?.Where(x => categories.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
?.Where(x => seedingRules.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, List<string> categories) =>
@@ -37,9 +37,9 @@ public partial class DelugeService
.ToList();
/// <inheritdoc/>
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent)
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles)
{
await DeleteDownload(torrent.Hash);
await DeleteDownload(torrent.Hash, deleteSourceFiles);
}
public override async Task CreateCategoryAsync(string name)
@@ -142,11 +142,11 @@ public partial class DelugeService
}
/// <inheritdoc/>
public override async Task DeleteDownload(string hash)
public override async Task DeleteDownload(string hash, bool deleteSourceFiles)
{
hash = hash.ToLowerInvariant();
await _client.DeleteTorrents([hash]);
await _client.DeleteTorrents([hash], deleteSourceFiles);
}
protected async Task CreateLabel(string name)

View File

@@ -12,7 +12,7 @@ public interface IDelugeClientWrapper
Task<DelugeTorrent?> GetTorrent(string hash);
Task<DelugeTorrentExtended?> GetTorrentExtended(string hash);
Task<List<DownloadStatus>?> GetStatusForAllTorrents();
Task DeleteTorrents(List<string> hashes);
Task DeleteTorrents(List<string> hashes, bool removeData);
Task ChangeFilesPriority(string hash, List<int> priorities);
Task<IReadOnlyList<string>> GetLabels();
Task CreateLabel(string label);

View File

@@ -82,19 +82,19 @@ public abstract class DownloadService : IDownloadService
public abstract Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads);
/// <inheritdoc/>
public abstract Task DeleteDownload(string hash);
public abstract Task DeleteDownload(string hash, bool deleteSourceFiles);
/// <inheritdoc/>
public abstract Task<List<ITorrentItemWrapper>> GetSeedingDownloads();
/// <inheritdoc/>
public abstract List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<CleanCategory> categories);
public abstract List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules);
/// <inheritdoc/>
public abstract List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, List<string> categories);
/// <inheritdoc/>
public virtual async Task CleanDownloadsAsync(List<ITorrentItemWrapper>? downloads, List<CleanCategory> categoriesToClean)
public virtual async Task CleanDownloadsAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules)
{
if (downloads?.Count is null or 0)
{
@@ -108,7 +108,7 @@ public abstract class DownloadService : IDownloadService
continue;
}
CleanCategory? category = categoriesToClean
SeedingRule? category = seedingRules
.FirstOrDefault(x => (torrent.Category ?? string.Empty).Equals(x.Name, StringComparison.InvariantCultureIgnoreCase));
if (category is null)
@@ -135,13 +135,14 @@ public abstract class DownloadService : IDownloadService
continue;
}
await _dryRunInterceptor.InterceptAsync(DeleteDownloadInternal, torrent);
await _dryRunInterceptor.InterceptAsync(() => DeleteDownloadInternal(torrent, category.DeleteSourceFiles));
_logger.LogInformation(
"download cleaned | {reason} reached | {name}",
"download cleaned | {reason} reached | delete files: {deleteFiles} | {name}",
result.Reason is CleanReason.MaxRatioReached
? "MAX_RATIO & MIN_SEED_TIME"
: "MAX_SEED_TIME",
category.DeleteSourceFiles,
torrent.Name
);
@@ -163,9 +164,10 @@ public abstract class DownloadService : IDownloadService
/// Each client implementation handles the deletion according to its API requirements.
/// </summary>
/// <param name="torrent">The torrent to delete</param>
protected abstract Task DeleteDownloadInternal(ITorrentItemWrapper torrent);
/// <param name="deleteSourceFiles">Whether to delete the source files along with the torrent</param>
protected abstract Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles);
protected SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, CleanCategory category)
protected SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, SeedingRule category)
{
// check ratio
if (DownloadReachedRatio(ratio, seedingTime, category))
@@ -210,7 +212,7 @@ public abstract class DownloadService : IDownloadService
return parts.Length > 0 ? Path.Combine(root, parts[0]) : root;
}
private bool DownloadReachedRatio(double ratio, TimeSpan seedingTime, CleanCategory category)
private bool DownloadReachedRatio(double ratio, TimeSpan seedingTime, SeedingRule category)
{
if (category.MaxRatio < 0)
{
@@ -236,7 +238,7 @@ public abstract class DownloadService : IDownloadService
return true;
}
private bool DownloadReachedMaxSeedTime(TimeSpan seedingTime, CleanCategory category)
private bool DownloadReachedMaxSeedTime(TimeSpan seedingTime, SeedingRule category)
{
if (category.MaxSeedTime < 0)
{

View File

@@ -36,9 +36,9 @@ public interface IDownloadService : IDisposable
/// Filters downloads that should be cleaned.
/// </summary>
/// <param name="downloads">The downloads to filter.</param>
/// <param name="categories">The categories by which to filter the downloads.</param>
/// <param name="seedingRules">The seeding rules by which to filter the downloads.</param>
/// <returns>A list of downloads for the provided categories.</returns>
List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<CleanCategory> categories);
List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules);
/// <summary>
/// Filters downloads that should have their category changed.
@@ -52,8 +52,8 @@ public interface IDownloadService : IDisposable
/// Cleans the downloads.
/// </summary>
/// <param name="downloads">The downloads to clean.</param>
/// <param name="categoriesToClean">The categories that should be cleaned.</param>
Task CleanDownloadsAsync(List<ITorrentItemWrapper>? downloads, List<CleanCategory> categoriesToClean);
/// <param name="seedingRules">The seeding rules.</param>
Task CleanDownloadsAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules);
/// <summary>
/// Changes the category for downloads that have no hardlinks.
@@ -64,7 +64,9 @@ public interface IDownloadService : IDisposable
/// <summary>
/// Deletes a download item.
/// </summary>
public Task DeleteDownload(string hash);
/// <param name="hash">The torrent hash.</param>
/// <param name="deleteSourceFiles">Whether to delete the source files along with the torrent. Defaults to true.</param>
public Task DeleteDownload(string hash, bool deleteSourceFiles);
/// <summary>
/// Creates a category.

View File

@@ -33,10 +33,10 @@ public partial class QBitService
}
/// <inheritdoc/>
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<CleanCategory> categories) =>
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules) =>
downloads
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => categories.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.Where(x => seedingRules.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
/// <inheritdoc/>
@@ -61,9 +61,9 @@ public partial class QBitService
}
/// <inheritdoc/>
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent)
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles)
{
await DeleteDownload(torrent.Hash);
await DeleteDownload(torrent.Hash, deleteSourceFiles);
}
public override async Task CreateCategoryAsync(string name)
@@ -175,9 +175,9 @@ public partial class QBitService
}
/// <inheritdoc/>
public override async Task DeleteDownload(string hash)
public override async Task DeleteDownload(string hash, bool deleteSourceFiles)
{
await _client.DeleteAsync([hash], deleteDownloadedData: true);
await _client.DeleteAsync([hash], deleteDownloadedData: deleteSourceFiles);
}
protected async Task CreateCategory(string name)

View File

@@ -21,10 +21,10 @@ public partial class TransmissionService
}
/// <inheritdoc/>
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<CleanCategory> categories)
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules)
{
return downloads
?.Where(x => categories
?.Where(x => seedingRules
.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))
)
.ToList();
@@ -39,10 +39,10 @@ public partial class TransmissionService
}
/// <inheritdoc/>
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent)
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles)
{
var transmissionTorrent = (TransmissionItemWrapper)torrent;
await RemoveDownloadAsync(transmissionTorrent.Info.Id);
await RemoveDownloadAsync(transmissionTorrent.Info.Id, deleteSourceFiles);
}
public override async Task CreateCategoryAsync(string name)
@@ -140,7 +140,7 @@ public partial class TransmissionService
await _client.TorrentSetLocationAsync([downloadId], newLocation, true);
}
public override async Task DeleteDownload(string hash)
public override async Task DeleteDownload(string hash, bool deleteSourceFiles)
{
TorrentInfo? torrent = await GetTorrentAsync(hash);
@@ -149,11 +149,11 @@ public partial class TransmissionService
return;
}
await _client.TorrentRemoveAsync([torrent.Id], true);
await _client.TorrentRemoveAsync([torrent.Id], deleteSourceFiles);
}
protected virtual async Task RemoveDownloadAsync(long downloadId)
protected virtual async Task RemoveDownloadAsync(long downloadId, bool deleteSourceFiles)
{
await _client.TorrentRemoveAsync([downloadId], true);
await _client.TorrentRemoveAsync([downloadId], deleteSourceFiles);
}
}

View File

@@ -13,5 +13,5 @@ public interface IUTorrentClientWrapper
Task<List<string>> GetLabelsAsync();
Task SetTorrentLabelAsync(string hash, string label);
Task SetFilesPriorityAsync(string hash, List<int> fileIndexes, int priority);
Task RemoveTorrentsAsync(List<string> hashes);
Task RemoveTorrentsAsync(List<string> hashes, bool deleteData);
}

View File

@@ -210,13 +210,16 @@ public sealed class UTorrentClient
/// Removes torrents from µTorrent
/// </summary>
/// <param name="hashes">List of torrent hashes to remove</param>
public async Task RemoveTorrentsAsync(List<string> hashes)
/// <param name="deleteData">Whether to delete the downloaded data files</param>
public async Task RemoveTorrentsAsync(List<string> hashes, bool deleteData)
{
try
{
foreach (var hash in hashes)
{
var request = UTorrentRequestFactory.CreateRemoveTorrentWithDataRequest(hash);
var request = deleteData
? UTorrentRequestFactory.CreateRemoveTorrentWithDataRequest(hash)
: UTorrentRequestFactory.CreateRemoveTorrentRequest(hash);
await SendAuthenticatedRequestAsync(request);
}
}

View File

@@ -38,6 +38,6 @@ public sealed class UTorrentClientWrapper : IUTorrentClientWrapper
public Task SetFilesPriorityAsync(string hash, List<int> fileIndexes, int priority)
=> _client.SetFilesPriorityAsync(hash, fileIndexes, priority);
public Task RemoveTorrentsAsync(List<string> hashes)
=> _client.RemoveTorrentsAsync(hashes);
public Task RemoveTorrentsAsync(List<string> hashes, bool deleteData)
=> _client.RemoveTorrentsAsync(hashes, deleteData);
}

View File

@@ -59,6 +59,17 @@ public static class UTorrentRequestFactory
.WithParameter("hash", hash);
}
/// <summary>
/// Creates a request to remove a torrent without deleting its data
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <returns>Request for remove torrent API call</returns>
public static UTorrentRequest CreateRemoveTorrentRequest(string hash)
{
return UTorrentRequest.Create("action=removetorrent", string.Empty)
.WithParameter("hash", hash);
}
/// <summary>
/// Creates a request to set file priorities for a torrent
/// </summary>

View File

@@ -24,9 +24,9 @@ public partial class UTorrentService
return result;
}
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<CleanCategory> categories) =>
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules) =>
downloads
?.Where(x => categories.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
?.Where(x => seedingRules.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, List<string> categories) =>
@@ -36,9 +36,9 @@ public partial class UTorrentService
.ToList();
/// <inheritdoc/>
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent)
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles)
{
await DeleteDownload(torrent.Hash);
await DeleteDownload(torrent.Hash, deleteSourceFiles);
}
public override async Task CreateCategoryAsync(string name)
@@ -124,11 +124,11 @@ public partial class UTorrentService
}
/// <inheritdoc/>
public override async Task DeleteDownload(string hash)
public override async Task DeleteDownload(string hash, bool deleteSourceFiles)
{
hash = hash.ToLowerInvariant();
await _client.RemoveTorrentsAsync([hash]);
await _client.RemoveTorrentsAsync([hash], deleteSourceFiles);
}
protected virtual async Task ChangeLabel(string hash, string newLabel)

View File

@@ -129,8 +129,6 @@ public sealed class DownloadCleaner : GenericHandler
await ChangeUnlinkedCategoriesAsync(isUnlinkedEnabled, downloadServiceToDownloadsMap, config);
await CleanDownloadsAsync(downloadServiceToDownloadsMap, config);
foreach (var downloadService in downloadServices)
{
downloadService.Dispose();

View File

@@ -79,8 +79,8 @@ public sealed class DownloadCleanerConfigTests
Enabled = true,
Categories =
[
new CleanCategory { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1 },
new CleanCategory { Name = "tv", MaxRatio = 1.5, MinSeedTime = 24, MaxSeedTime = -1 }
new SeedingRule { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "tv", MaxRatio = 1.5, MinSeedTime = 24, MaxSeedTime = -1, DeleteSourceFiles = true }
],
UnlinkedEnabled = false
};
@@ -96,8 +96,8 @@ public sealed class DownloadCleanerConfigTests
Enabled = true,
Categories =
[
new CleanCategory { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1 },
new CleanCategory { Name = "movies", MaxRatio = 1.5, MinSeedTime = 24, MaxSeedTime = -1 }
new SeedingRule { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "movies", MaxRatio = 1.5, MinSeedTime = 24, MaxSeedTime = -1, DeleteSourceFiles = true }
],
UnlinkedEnabled = false
};
@@ -114,7 +114,7 @@ public sealed class DownloadCleanerConfigTests
Enabled = true,
Categories =
[
new CleanCategory { Name = "", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1 }
new SeedingRule { Name = "", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
],
UnlinkedEnabled = false
};
@@ -151,7 +151,7 @@ public sealed class DownloadCleanerConfigTests
Enabled = true,
Categories =
[
new CleanCategory { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1 }
new SeedingRule { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
],
UnlinkedEnabled = true,
UnlinkedTargetCategory = "",
@@ -171,7 +171,7 @@ public sealed class DownloadCleanerConfigTests
Enabled = true,
Categories =
[
new CleanCategory { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1 }
new SeedingRule { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
],
UnlinkedEnabled = true,
UnlinkedTargetCategory = "cleanuparr-unlinked",
@@ -259,7 +259,7 @@ public sealed class DownloadCleanerConfigTests
Enabled = true,
Categories =
[
new CleanCategory { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1 }
new SeedingRule { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
],
UnlinkedEnabled = true,
UnlinkedTargetCategory = "cleanuparr-unlinked",

View File

@@ -5,19 +5,20 @@ using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Tests.Models.Configuration.DownloadCleaner;
public sealed class CleanCategoryTests
public sealed class SeedingRuleTests
{
#region Validate - Valid Configurations
[Fact]
public void Validate_WithValidMaxRatio_DoesNotThrow()
{
var config = new CleanCategory
var config = new SeedingRule
{
Name = "test-category",
MaxRatio = 2.0,
MinSeedTime = 0,
MaxSeedTime = -1
MaxSeedTime = -1,
DeleteSourceFiles = true
};
Should.NotThrow(() => config.Validate());
@@ -26,12 +27,13 @@ public sealed class CleanCategoryTests
[Fact]
public void Validate_WithValidMaxSeedTime_DoesNotThrow()
{
var config = new CleanCategory
var config = new SeedingRule
{
Name = "test-category",
MaxRatio = -1,
MinSeedTime = 0,
MaxSeedTime = 24
MaxSeedTime = 24,
DeleteSourceFiles = true
};
Should.NotThrow(() => config.Validate());
@@ -40,12 +42,13 @@ public sealed class CleanCategoryTests
[Fact]
public void Validate_WithBothMaxRatioAndMaxSeedTime_DoesNotThrow()
{
var config = new CleanCategory
var config = new SeedingRule
{
Name = "test-category",
MaxRatio = 2.0,
MinSeedTime = 1,
MaxSeedTime = 48
MaxSeedTime = 48,
DeleteSourceFiles = true
};
Should.NotThrow(() => config.Validate());
@@ -54,12 +57,13 @@ public sealed class CleanCategoryTests
[Fact]
public void Validate_WithZeroMaxRatio_DoesNotThrow()
{
var config = new CleanCategory
var config = new SeedingRule
{
Name = "test-category",
MaxRatio = 0,
MinSeedTime = 0,
MaxSeedTime = -1
MaxSeedTime = -1,
DeleteSourceFiles = true
};
Should.NotThrow(() => config.Validate());
@@ -68,12 +72,13 @@ public sealed class CleanCategoryTests
[Fact]
public void Validate_WithZeroMaxSeedTime_DoesNotThrow()
{
var config = new CleanCategory
var config = new SeedingRule
{
Name = "test-category",
MaxRatio = -1,
MinSeedTime = 0,
MaxSeedTime = 0
MaxSeedTime = 0,
DeleteSourceFiles = true
};
Should.NotThrow(() => config.Validate());
@@ -86,12 +91,13 @@ public sealed class CleanCategoryTests
[Fact]
public void Validate_WithEmptyName_ThrowsValidationException()
{
var config = new CleanCategory
var config = new SeedingRule
{
Name = "",
MaxRatio = 2.0,
MinSeedTime = 0,
MaxSeedTime = -1
MaxSeedTime = -1,
DeleteSourceFiles = true
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
@@ -101,12 +107,13 @@ public sealed class CleanCategoryTests
[Fact]
public void Validate_WithWhitespaceName_ThrowsValidationException()
{
var config = new CleanCategory
var config = new SeedingRule
{
Name = " ",
MaxRatio = 2.0,
MinSeedTime = 0,
MaxSeedTime = -1
MaxSeedTime = -1,
DeleteSourceFiles = true
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
@@ -116,12 +123,13 @@ public sealed class CleanCategoryTests
[Fact]
public void Validate_WithTabOnlyName_ThrowsValidationException()
{
var config = new CleanCategory
var config = new SeedingRule
{
Name = "\t",
MaxRatio = 2.0,
MinSeedTime = 0,
MaxSeedTime = -1
MaxSeedTime = -1,
DeleteSourceFiles = true
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
@@ -135,12 +143,13 @@ public sealed class CleanCategoryTests
[Fact]
public void Validate_WithBothNegative_ThrowsValidationException()
{
var config = new CleanCategory
var config = new SeedingRule
{
Name = "test-category",
MaxRatio = -1,
MinSeedTime = 0,
MaxSeedTime = -1
MaxSeedTime = -1,
DeleteSourceFiles = true
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
@@ -153,12 +162,13 @@ public sealed class CleanCategoryTests
[InlineData(-100, -100)]
public void Validate_WithVariousNegativeValues_ThrowsValidationException(double maxRatio, double maxSeedTime)
{
var config = new CleanCategory
var config = new SeedingRule
{
Name = "test-category",
MaxRatio = maxRatio,
MinSeedTime = 0,
MaxSeedTime = maxSeedTime
MaxSeedTime = maxSeedTime,
DeleteSourceFiles = true
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
@@ -172,12 +182,13 @@ public sealed class CleanCategoryTests
[Fact]
public void Validate_WithNegativeMinSeedTime_ThrowsValidationException()
{
var config = new CleanCategory
var config = new SeedingRule
{
Name = "test-category",
MaxRatio = 2.0,
MinSeedTime = -1,
MaxSeedTime = -1
MaxSeedTime = -1,
DeleteSourceFiles = true
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
@@ -190,12 +201,13 @@ public sealed class CleanCategoryTests
[InlineData(-100)]
public void Validate_WithVariousNegativeMinSeedTime_ThrowsValidationException(double minSeedTime)
{
var config = new CleanCategory
var config = new SeedingRule
{
Name = "test-category",
MaxRatio = 2.0,
MinSeedTime = minSeedTime,
MaxSeedTime = -1
MaxSeedTime = -1,
DeleteSourceFiles = true
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
@@ -205,12 +217,13 @@ public sealed class CleanCategoryTests
[Fact]
public void Validate_WithZeroMinSeedTime_DoesNotThrow()
{
var config = new CleanCategory
var config = new SeedingRule
{
Name = "test-category",
MaxRatio = 2.0,
MinSeedTime = 0,
MaxSeedTime = -1
MaxSeedTime = -1,
DeleteSourceFiles = true
};
Should.NotThrow(() => config.Validate());
@@ -219,16 +232,32 @@ public sealed class CleanCategoryTests
[Fact]
public void Validate_WithPositiveMinSeedTime_DoesNotThrow()
{
var config = new CleanCategory
var config = new SeedingRule
{
Name = "test-category",
MaxRatio = 2.0,
MinSeedTime = 24,
MaxSeedTime = -1
MaxSeedTime = -1,
DeleteSourceFiles = true
};
Should.NotThrow(() => config.Validate());
}
#endregion
[Fact]
public void DeleteSourceFiles_CanBeSetToFalse()
{
var config = new SeedingRule
{
Name = "test-category",
MaxRatio = 2.0,
MinSeedTime = 0,
MaxSeedTime = -1,
DeleteSourceFiles = false
};
config.DeleteSourceFiles.ShouldBeFalse();
}
}

View File

@@ -38,7 +38,7 @@ public class DataContext : DbContext
public DbSet<DownloadCleanerConfig> DownloadCleanerConfigs { get; set; }
public DbSet<CleanCategory> CleanCategories { get; set; }
public DbSet<SeedingRule> SeedingRules { get; set; }
public DbSet<ArrConfig> ArrConfigs { get; set; }

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddDeleteSourceFilesToCleanCategory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "delete_source_files",
table: "clean_categories",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.Sql("UPDATE clean_categories SET delete_source_files = 1");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "delete_source_files",
table: "clean_categories");
}
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class RenameCleanCategoryToSeedingRule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "seeding_rules",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
download_cleaner_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
name = table.Column<string>(type: "TEXT", nullable: false),
max_ratio = table.Column<double>(type: "REAL", nullable: false),
min_seed_time = table.Column<double>(type: "REAL", nullable: false),
max_seed_time = table.Column<double>(type: "REAL", nullable: false),
delete_source_files = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_seeding_rules", x => x.id);
table.ForeignKey(
name: "fk_seeding_rules_download_cleaner_configs_download_cleaner_config_id",
column: x => x.download_cleaner_config_id,
principalTable: "download_cleaner_configs",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_seeding_rules_download_cleaner_config_id",
table: "seeding_rules",
column: "download_cleaner_config_id");
migrationBuilder.Sql(@"
INSERT INTO seeding_rules (id, download_cleaner_config_id, name, max_ratio, min_seed_time, max_seed_time, delete_source_files)
SELECT id, download_cleaner_config_id, name, max_ratio, min_seed_time, max_seed_time, delete_source_files
FROM clean_categories;
");
migrationBuilder.DropTable(
name: "clean_categories");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "clean_categories",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
download_cleaner_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
name = table.Column<string>(type: "TEXT", nullable: false),
max_ratio = table.Column<double>(type: "REAL", nullable: false),
min_seed_time = table.Column<double>(type: "REAL", nullable: false),
max_seed_time = table.Column<double>(type: "REAL", nullable: false),
delete_source_files = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_clean_categories", x => x.id);
table.ForeignKey(
name: "fk_clean_categories_download_cleaner_configs_download_cleaner_config_id",
column: x => x.download_cleaner_config_id,
principalTable: "download_cleaner_configs",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_clean_categories_download_cleaner_config_id",
table: "clean_categories",
column: "download_cleaner_config_id");
migrationBuilder.Sql(@"
INSERT INTO clean_categories (id, download_cleaner_config_id, name, max_ratio, min_seed_time, max_seed_time, delete_source_files)
SELECT id, download_cleaner_config_id, name, max_ratio, min_seed_time, max_seed_time, delete_source_files
FROM seeding_rules;
");
migrationBuilder.DropTable(
name: "seeding_rules");
}
}
}

View File

@@ -105,43 +105,6 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.ToTable("blacklist_sync_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("DownloadCleanerConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_cleaner_config_id");
b.Property<double>("MaxRatio")
.HasColumnType("REAL")
.HasColumnName("max_ratio");
b.Property<double>("MaxSeedTime")
.HasColumnType("REAL")
.HasColumnName("max_seed_time");
b.Property<double>("MinSeedTime")
.HasColumnType("REAL")
.HasColumnName("min_seed_time");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id")
.HasName("pk_clean_categories");
b.HasIndex("DownloadCleanerConfigId")
.HasDatabaseName("ix_clean_categories_download_cleaner_config_id");
b.ToTable("clean_categories", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Property<Guid>("Id")
@@ -200,6 +163,47 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.ToTable("download_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.SeedingRule", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("DeleteSourceFiles")
.HasColumnType("INTEGER")
.HasColumnName("delete_source_files");
b.Property<Guid>("DownloadCleanerConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_cleaner_config_id");
b.Property<double>("MaxRatio")
.HasColumnType("REAL")
.HasColumnName("max_ratio");
b.Property<double>("MaxSeedTime")
.HasColumnType("REAL")
.HasColumnName("max_seed_time");
b.Property<double>("MinSeedTime")
.HasColumnType("REAL")
.HasColumnName("min_seed_time");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id")
.HasName("pk_seeding_rules");
b.HasIndex("DownloadCleanerConfigId")
.HasDatabaseName("ix_seeding_rules_download_cleaner_config_id");
b.ToTable("seeding_rules", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b =>
{
b.Property<Guid>("Id")
@@ -959,14 +963,14 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.Navigation("ArrConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.SeedingRule", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig")
.WithMany("Categories")
.HasForeignKey("DownloadCleanerConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_clean_categories_download_cleaner_configs_download_cleaner_config_id");
.HasConstraintName("fk_seeding_rules_download_cleaner_configs_download_cleaner_config_id");
b.Navigation("DownloadCleanerConfig");
});

View File

@@ -19,7 +19,7 @@ public sealed record DownloadCleanerConfig : IJobConfig
/// </summary>
public bool UseAdvancedScheduling { get; set; }
public List<CleanCategory> Categories { get; set; } = [];
public List<SeedingRule> Categories { get; set; } = [];
public bool DeletePrivate { get; set; }

View File

@@ -4,7 +4,7 @@ using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
public sealed record CleanCategory : IConfig
public sealed record SeedingRule : IConfig
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
@@ -31,6 +31,11 @@ public sealed record CleanCategory : IConfig
/// </summary>
public required double MaxSeedTime { get; init; } = -1;
/// <summary>
/// Whether to delete the source files when cleaning the download.
/// </summary>
public required bool DeleteSourceFiles { get; init; }
public void Validate()
{
if (string.IsNullOrEmpty(Name.Trim()))

View File

@@ -72,6 +72,7 @@ export class DocumentationService {
'maxRatio': 'max-ratio',
'minSeedTime': 'min-seed-time',
'maxSeedTime': 'max-seed-time',
'deleteSourceFiles': 'delete-source-files',
'unlinkedEnabled': 'enable-unlinked-download-handling',
'unlinkedTargetCategory': 'target-category',
'unlinkedUseTag': 'use-tag',

View File

@@ -243,6 +243,19 @@
<small class="form-helper-text">Maximum time to seed before removing (<code>-1</code> means disabled)</small>
</div>
</div>
<div class="category-field">
<label>
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('deleteSourceFiles')"
title="Click for documentation"></i>
Delete Source Files
</label>
<div class="field-input">
<p-checkbox formControlName="deleteSourceFiles" [binary]="true" [inputId]="'deleteSourceFiles_' + i"></p-checkbox>
<small class="form-helper-text">When enabled, the source files will be deleted when the download is removed</small>
</div>
</div>
</div>
<!-- Error for both maxRatio and maxSeedTime disabled -->
<small *ngIf="hasCategoryGroupError(i, 'bothDisabled')" class="form-error-text">

View File

@@ -213,6 +213,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
maxRatio: [category.maxRatio, [Validators.min(-1), Validators.required]],
minSeedTime: [category.minSeedTime, [Validators.min(0), Validators.required]],
maxSeedTime: [category.maxSeedTime, [Validators.min(-1), Validators.required]],
deleteSourceFiles: [category.deleteSourceFiles],
}, { validators: this.validateCategory });
}

View File

@@ -22,6 +22,7 @@ export interface CleanCategory {
maxRatio: number;
minSeedTime: number; // hours
maxSeedTime: number; // hours
deleteSourceFiles: boolean;
}
export interface JobSchedule {
@@ -35,7 +36,8 @@ export function createDefaultCategory(): CleanCategory {
name: '',
maxRatio: -1, // -1 means disabled
minSeedTime: 0,
maxSeedTime: -1 // -1 means disabled
maxSeedTime: -1, // -1 means disabled
deleteSourceFiles: true
};
}

View File

@@ -165,6 +165,15 @@ Maximum time in hours to seed before removing a download regardless of ratio. Se
</ConfigSection>
<ConfigSection
title="Delete Source Files"
icon="🗑️"
>
When enabled, the source files will be deleted from disk when the download is removed from the download client. When disabled, only the torrent entry is removed while preserving the underlying files.
</ConfigSection>
</div>
<div className={styles.section}>