Compare commits

...

4 Commits

Author SHA1 Message Date
Flaminel
375094862c Add test button for arrs and download clients (#391) 2025-12-20 17:06:03 +02:00
Flaminel
58a72cef0f Add option for multiple ignored root directories (#390) 2025-12-20 17:04:36 +02:00
Flaminel
4ceff127a7 Add option to keep source files when cleaning downloads (#388) 2025-12-19 23:52:59 +02:00
Flaminel
c07b811cf8 Fix Transmission torrent fetch (#389) 2025-12-19 23:35:23 +02:00
88 changed files with 5043 additions and 480 deletions

View File

@@ -177,7 +177,7 @@ public class StatusController : ControllerBase
try
{
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr);
await sonarrClient.TestConnectionAsync(instance);
await sonarrClient.HealthCheckAsync(instance);
sonarrStatus.Add(new
{
@@ -209,7 +209,7 @@ public class StatusController : ControllerBase
try
{
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr);
await radarrClient.TestConnectionAsync(instance);
await radarrClient.HealthCheckAsync(instance);
radarrStatus.Add(new
{
@@ -241,7 +241,7 @@ public class StatusController : ControllerBase
try
{
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr);
await lidarrClient.TestConnectionAsync(instance);
await lidarrClient.HealthCheckAsync(instance);
lidarrStatus.Add(new
{

View File

@@ -44,8 +44,8 @@ public static class ServicesDI
.AddScoped<IDownloadHunter, DownloadHunter>()
.AddScoped<IFilenameEvaluator, FilenameEvaluator>()
.AddScoped<IHardLinkFileService, HardLinkFileService>()
.AddScoped<UnixHardLinkFileService>()
.AddScoped<WindowsHardLinkFileService>()
.AddScoped<IUnixHardLinkFileService, UnixHardLinkFileService>()
.AddScoped<IWindowsHardLinkFileService, WindowsHardLinkFileService>()
.AddScoped<IArrQueueIterator, ArrQueueIterator>()
.AddScoped<IDownloadServiceFactory, DownloadServiceFactory>()
.AddScoped<IStriker, Striker>()

View File

@@ -0,0 +1,24 @@
using System;
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Persistence.Models.Configuration.Arr;
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
public sealed record TestArrInstanceRequest
{
[Required]
public required string Url { get; init; }
[Required]
public required string ApiKey { get; init; }
public ArrInstance ToTestInstance() => new()
{
Enabled = true,
Name = "Test Instance",
Url = new Uri(Url),
ApiKey = ApiKey,
ArrConfigId = Guid.Empty,
};
}

View File

@@ -5,6 +5,7 @@ using System.Threading.Tasks;
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Mapster;
@@ -20,13 +21,16 @@ public sealed class ArrConfigController : ControllerBase
{
private readonly ILogger<ArrConfigController> _logger;
private readonly DataContext _dataContext;
private readonly IArrClientFactory _arrClientFactory;
public ArrConfigController(
ILogger<ArrConfigController> logger,
DataContext dataContext)
DataContext dataContext,
IArrClientFactory arrClientFactory)
{
_logger = logger;
_dataContext = dataContext;
_arrClientFactory = arrClientFactory;
}
[HttpGet("sonarr")]
@@ -124,6 +128,26 @@ public sealed class ArrConfigController : ControllerBase
public Task<IActionResult> DeleteWhisparrInstance(Guid id)
=> DeleteArrInstance(InstanceType.Whisparr, id);
[HttpPost("sonarr/instances/test")]
public Task<IActionResult> TestSonarrInstance([FromBody] TestArrInstanceRequest request)
=> TestArrInstance(InstanceType.Sonarr, request);
[HttpPost("radarr/instances/test")]
public Task<IActionResult> TestRadarrInstance([FromBody] TestArrInstanceRequest request)
=> TestArrInstance(InstanceType.Radarr, request);
[HttpPost("lidarr/instances/test")]
public Task<IActionResult> TestLidarrInstance([FromBody] TestArrInstanceRequest request)
=> TestArrInstance(InstanceType.Lidarr, request);
[HttpPost("readarr/instances/test")]
public Task<IActionResult> TestReadarrInstance([FromBody] TestArrInstanceRequest request)
=> TestArrInstance(InstanceType.Readarr, request);
[HttpPost("whisparr/instances/test")]
public Task<IActionResult> TestWhisparrInstance([FromBody] TestArrInstanceRequest request)
=> TestArrInstance(InstanceType.Whisparr, request);
private async Task<IActionResult> GetArrConfig(InstanceType type)
{
await DataContext.Lock.WaitAsync();
@@ -260,6 +284,23 @@ public sealed class ArrConfigController : ControllerBase
}
}
private async Task<IActionResult> TestArrInstance(InstanceType type, TestArrInstanceRequest request)
{
try
{
var testInstance = request.ToTestInstance();
var client = _arrClientFactory.GetClient(type);
await client.HealthCheckAsync(testInstance);
return Ok(new { Message = $"Connection to {type} instance successful" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test {Type} instance connection", type);
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
}
}
private static string GetConfigActionName(InstanceType type) => type switch
{
InstanceType.Sonarr => nameof(GetSonarrConfig),

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; }
@@ -26,30 +24,9 @@ public record UpdateDownloadCleanerConfigRequest
public bool UnlinkedUseTag { get; init; }
public string UnlinkedIgnoredRootDir { get; init; } = string.Empty;
public List<string> UnlinkedIgnoredRootDirs { get; init; } = [];
public List<string> UnlinkedCategories { get; init; } = [];
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

@@ -80,22 +80,23 @@ public sealed class DownloadCleanerConfigController : ControllerBase
oldConfig.UnlinkedEnabled = newConfigDto.UnlinkedEnabled;
oldConfig.UnlinkedTargetCategory = newConfigDto.UnlinkedTargetCategory;
oldConfig.UnlinkedUseTag = newConfigDto.UnlinkedUseTag;
oldConfig.UnlinkedIgnoredRootDir = newConfigDto.UnlinkedIgnoredRootDir;
oldConfig.UnlinkedIgnoredRootDirs = newConfigDto.UnlinkedIgnoredRootDirs;
oldConfig.UnlinkedCategories = newConfigDto.UnlinkedCategories;
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

@@ -0,0 +1,43 @@
using System;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
public sealed record TestDownloadClientRequest
{
public DownloadClientTypeName TypeName { get; init; }
public DownloadClientType Type { get; init; }
public Uri? Host { get; init; }
public string? Username { get; init; }
public string? Password { get; init; }
public string? UrlBase { get; init; }
public void Validate()
{
if (Host is null)
{
throw new ValidationException("Host cannot be empty");
}
}
public DownloadClientConfig ToTestConfig() => new()
{
Id = Guid.NewGuid(),
Enabled = true,
Name = "Test Client",
TypeName = TypeName,
Type = Type,
Host = Host,
Username = Username,
Password = Password,
UrlBase = UrlBase,
};
}

View File

@@ -2,6 +2,7 @@ using System;
using System.Linq;
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
@@ -18,15 +19,18 @@ public sealed class DownloadClientController : ControllerBase
private readonly ILogger<DownloadClientController> _logger;
private readonly DataContext _dataContext;
private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory;
private readonly IDownloadServiceFactory _downloadServiceFactory;
public DownloadClientController(
ILogger<DownloadClientController> logger,
DataContext dataContext,
IDynamicHttpClientFactory dynamicHttpClientFactory)
IDynamicHttpClientFactory dynamicHttpClientFactory,
IDownloadServiceFactory downloadServiceFactory)
{
_logger = logger;
_dataContext = dataContext;
_dynamicHttpClientFactory = dynamicHttpClientFactory;
_downloadServiceFactory = downloadServiceFactory;
}
[HttpGet("download_client")]
@@ -146,4 +150,33 @@ public sealed class DownloadClientController : ControllerBase
DataContext.Lock.Release();
}
}
[HttpPost("download_client/test")]
public async Task<IActionResult> TestDownloadClient([FromBody] TestDownloadClientRequest request)
{
try
{
request.Validate();
var testConfig = request.ToTestConfig();
using var downloadService = _downloadServiceFactory.GetDownloadService(testConfig);
var healthResult = await downloadService.HealthCheckAsync();
if (healthResult.IsHealthy)
{
return Ok(new
{
Message = $"Connection to {request.TypeName} successful",
ResponseTime = healthResult.ResponseTime.TotalMilliseconds
});
}
return BadRequest(new { Message = healthResult.ErrorMessage ?? "Connection failed" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test {TypeName} client connection", request.TypeName);
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
}
}
}

View File

@@ -586,12 +586,12 @@ public sealed class NotificationProvidersController : ControllerBase
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
return Ok(new { Message = "Test notification sent successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Notifiarr provider");
throw;
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
@@ -627,12 +627,12 @@ public sealed class NotificationProvidersController : ControllerBase
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
return Ok(new { Message = "Test notification sent successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Apprise provider");
throw;
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
@@ -673,12 +673,12 @@ public sealed class NotificationProvidersController : ControllerBase
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
return Ok(new { Message = "Test notification sent successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Ntfy provider");
throw;
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
@@ -899,12 +899,12 @@ public sealed class NotificationProvidersController : ControllerBase
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
return Ok(new { Message = "Test notification sent successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Pushover provider");
throw;
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
}

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);
}
}
@@ -696,7 +716,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked",
UnlinkedIgnoredRootDir = "/ignore"
UnlinkedIgnoredRootDirs = ["/ignore"]
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
@@ -724,7 +744,7 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Assert
_fixture.HardLinkFileService.Verify(
x => x.PopulateFileCounts("/ignore"),
x => x.PopulateFileCounts(It.Is<IEnumerable<string>>(dirs => dirs.Contains("/ignore"))),
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(
@@ -900,7 +900,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
Id = Guid.NewGuid(),
UnlinkedUseTag = false,
UnlinkedTargetCategory = "unlinked",
UnlinkedIgnoredRootDir = "/ignore"
UnlinkedIgnoredRootDirs = ["/ignore"]
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
@@ -925,7 +925,7 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Assert
_fixture.HardLinkFileService.Verify(
x => x.PopulateFileCounts("/ignore"),
x => x.PopulateFileCounts(It.Is<IEnumerable<string>>(dirs => dirs.Contains("/ignore"))),
Times.Once);
}

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(
@@ -787,7 +787,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked",
UnlinkedIgnoredRootDir = "/ignore"
UnlinkedIgnoredRootDirs = ["/ignore"]
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
@@ -813,7 +813,7 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Assert
_fixture.HardLinkFileService.Verify(
x => x.PopulateFileCounts("/ignore"),
x => x.PopulateFileCounts(It.Is<IEnumerable<string>>(dirs => dirs.Contains("/ignore"))),
Times.Once);
}

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);
}
}
@@ -619,7 +639,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked",
UnlinkedIgnoredRootDir = "/ignore"
UnlinkedIgnoredRootDirs = ["/ignore"]
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
@@ -646,7 +666,7 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Assert
_fixture.HardLinkFileService.Verify(
x => x.PopulateFileCounts("/ignore"),
x => x.PopulateFileCounts(It.Is<IEnumerable<string>>(dirs => dirs.Contains("/ignore"))),
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

@@ -168,16 +168,12 @@ public abstract class ArrClient : IArrClient
return true;
}
/// <summary>
/// Tests the connection to an Arr instance
/// </summary>
/// <param name="arrInstance">The instance to test connection to</param>
/// <returns>Task that completes when the connection test is done</returns>
public virtual async Task TestConnectionAsync(ArrInstance arrInstance)
/// <inheritdoc/>
public async Task HealthCheckAsync(ArrInstance arrInstance)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/system/status";
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}{GetSystemStatusUrlPath()}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
@@ -188,6 +184,8 @@ public abstract class ArrClient : IArrClient
_logger.LogDebug("Connection test successful for {url}", arrInstance.Url);
}
protected abstract string GetSystemStatusUrlPath();
protected abstract string GetQueueUrlPath();

View File

@@ -16,11 +16,11 @@ public interface IArrClient
Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
bool IsRecordValid(QueueRecord record);
/// <summary>
/// Tests the connection to an Arr instance
/// </summary>
/// <param name="arrInstance">The instance to test connection to</param>
/// <returns>Task that completes when the connection test is done</returns>
Task TestConnectionAsync(ArrInstance arrInstance);
Task HealthCheckAsync(ArrInstance arrInstance);
}

View File

@@ -22,6 +22,11 @@ public class LidarrClient : ArrClient, ILidarrClient
{
}
protected override string GetSystemStatusUrlPath()
{
return "/api/v1/system/status";
}
protected override string GetQueueUrlPath()
{
return "/api/v1/queue";

View File

@@ -21,6 +21,11 @@ public class RadarrClient : ArrClient, IRadarrClient
) : base(logger, httpClientFactory, striker, dryRunInterceptor)
{
}
protected override string GetSystemStatusUrlPath()
{
return "/api/v3/system/status";
}
protected override string GetQueueUrlPath()
{

View File

@@ -21,6 +21,11 @@ public class ReadarrClient : ArrClient, IReadarrClient
) : base(logger, httpClientFactory, striker, dryRunInterceptor)
{
}
protected override string GetSystemStatusUrlPath()
{
return "/api/v1/system/status";
}
protected override string GetQueueUrlPath()
{

View File

@@ -25,6 +25,11 @@ public class SonarrClient : ArrClient, ISonarrClient
{
}
protected override string GetSystemStatusUrlPath()
{
return "/api/v3/system/status";
}
protected override string GetQueueUrlPath()
{
return "/api/v3/queue";

View File

@@ -25,6 +25,11 @@ public class WhisparrClient : ArrClient, IWhisparrClient
{
}
protected override string GetSystemStatusUrlPath()
{
return "/api/v3/system/status";
}
protected override string GetQueueUrlPath()
{
return "/api/v3/queue";

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)
@@ -65,9 +65,9 @@ public partial class DelugeService
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
if (!string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir))
if (downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0)
{
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDir);
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDirs);
}
foreach (DelugeItemWrapper torrent in downloads.Cast<DelugeItemWrapper>())
@@ -105,7 +105,7 @@ public partial class DelugeService
}
long hardlinkCount = _hardLinkFileService
.GetHardLinkCount(filePath, !string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir));
.GetHardLinkCount(filePath, downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{
@@ -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)
@@ -89,9 +89,9 @@ public partial class QBitService
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
if (!string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir))
if (downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0)
{
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDir);
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDirs);
}
foreach (QBitItemWrapper torrent in downloads.Cast<QBitItemWrapper>())
@@ -131,7 +131,7 @@ public partial class QBitService
continue;
}
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, !string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir));
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{
@@ -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

@@ -8,7 +8,7 @@ namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
/// </summary>
public interface ITransmissionClientWrapper
{
Task<SessionInfo> GetSessionInformationAsync();
Task<SessionInfo?> GetSessionInformationAsync();
Task<TransmissionTorrents?> TorrentGetAsync(string[] fields, string? hash = null);
Task TorrentSetAsync(TorrentSettings settings);
Task TorrentSetLocationAsync(long[] ids, string location, bool move);

View File

@@ -16,11 +16,18 @@ public sealed class TransmissionClientWrapper : ITransmissionClientWrapper
_client = client;
}
public Task<SessionInfo> GetSessionInformationAsync()
public Task<SessionInfo?> GetSessionInformationAsync()
=> _client.GetSessionInformationAsync();
public Task<TransmissionTorrents?> TorrentGetAsync(string[] fields, string? hash = null)
=> _client.TorrentGetAsync(fields, hash);
{
if (hash is null)
{
return _client.TorrentGetAsync(fields);
}
return _client.TorrentGetAsync(fields, hash);
}
public Task TorrentSetAsync(TorrentSettings settings)
=> _client.TorrentSetAsync(settings);

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)
@@ -59,9 +59,9 @@ public partial class TransmissionService
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
if (!string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir))
if (downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0)
{
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDir);
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDirs);
}
foreach (TransmissionItemWrapper torrent in downloads.Cast<TransmissionItemWrapper>())
@@ -95,7 +95,7 @@ public partial class TransmissionService
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(torrent.Info.DownloadDir, file.Name).Split(['\\', '/']));
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, !string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir));
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{
@@ -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)
@@ -55,9 +55,9 @@ public partial class UTorrentService
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
if (!string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir))
if (downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0)
{
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDir);
_hardLinkFileService.PopulateFileCounts(downloadCleanerConfig.UnlinkedIgnoredRootDirs);
}
foreach (UTorrentItemWrapper torrent in downloads.Cast<UTorrentItemWrapper>())
@@ -86,7 +86,7 @@ public partial class UTorrentService
}
long hardlinkCount = _hardLinkFileService
.GetHardLinkCount(filePath, !string.IsNullOrEmpty(downloadCleanerConfig.UnlinkedIgnoredRootDir));
.GetHardLinkCount(filePath, downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{
@@ -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

@@ -6,13 +6,13 @@ namespace Cleanuparr.Infrastructure.Features.Files;
public class HardLinkFileService : IHardLinkFileService
{
private readonly ILogger<HardLinkFileService> _logger;
private readonly UnixHardLinkFileService _unixHardLinkFileService;
private readonly WindowsHardLinkFileService _windowsHardLinkFileService;
private readonly IUnixHardLinkFileService _unixHardLinkFileService;
private readonly IWindowsHardLinkFileService _windowsHardLinkFileService;
public HardLinkFileService(
ILogger<HardLinkFileService> logger,
UnixHardLinkFileService unixHardLinkFileService,
WindowsHardLinkFileService windowsHardLinkFileService
IUnixHardLinkFileService unixHardLinkFileService,
IWindowsHardLinkFileService windowsHardLinkFileService
)
{
_logger = logger;
@@ -23,16 +23,24 @@ public class HardLinkFileService : IHardLinkFileService
public void PopulateFileCounts(string directoryPath)
{
_logger.LogTrace("populating file counts from {dir}", directoryPath);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
_windowsHardLinkFileService.PopulateFileCounts(directoryPath);
return;
}
_unixHardLinkFileService.PopulateFileCounts(directoryPath);
}
public void PopulateFileCounts(IEnumerable<string> directoryPaths)
{
foreach (var directoryPath in directoryPaths.Where(d => !string.IsNullOrEmpty(d)))
{
PopulateFileCounts(directoryPath);
}
}
public long GetHardLinkCount(string filePath, bool ignoreRootDir)
{
if (!File.Exists(filePath))

View File

@@ -8,6 +8,12 @@ public interface IHardLinkFileService
/// </summary>
/// <param name="directoryPath">The root directory where to search for hardlinks.</param>
void PopulateFileCounts(string directoryPath);
/// <summary>
/// Populates the inode counts for Unix and the file index counts for Windows from multiple directories.
/// </summary>
/// <param name="directoryPaths">The root directories where to search for hardlinks.</param>
void PopulateFileCounts(IEnumerable<string> directoryPaths);
/// <summary>
/// Get the hardlink count of a file.

View File

@@ -0,0 +1,19 @@
namespace Cleanuparr.Infrastructure.Features.Files;
public interface ISpecificFileService
{
/// <summary>
/// Populates the inode counts for Unix and the file index counts for Windows.
/// Needs to be called before <see cref="GetHardLinkCount"/> to populate the inode counts.
/// </summary>
/// <param name="directoryPath">The root directory where to search for hardlinks.</param>
void PopulateFileCounts(string directoryPath);
/// <summary>
/// Get the hardlink count of a file.
/// </summary>
/// <param name="filePath">File path.</param>
/// <param name="ignoreRootDir">Whether to ignore hardlinks found in the same root dir.</param>
/// <returns>-1 on error, 0 if there are no hardlinks and 1 otherwise.</returns>
long GetHardLinkCount(string filePath, bool ignoreRootDir);
}

View File

@@ -0,0 +1,5 @@
namespace Cleanuparr.Infrastructure.Features.Files;
public interface IUnixHardLinkFileService : ISpecificFileService
{
}

View File

@@ -0,0 +1,5 @@
namespace Cleanuparr.Infrastructure.Features.Files;
public interface IWindowsHardLinkFileService : ISpecificFileService
{
}

View File

@@ -1,10 +1,10 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Mono.Unix.Native;
namespace Cleanuparr.Infrastructure.Features.Files;
public class UnixHardLinkFileService : IHardLinkFileService, IDisposable
public class UnixHardLinkFileService : IUnixHardLinkFileService, IDisposable
{
private readonly ILogger<UnixHardLinkFileService> _logger;
private readonly ConcurrentDictionary<ulong, int> _inodeCounts = new();

View File

@@ -1,11 +1,11 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
using Microsoft.Win32.SafeHandles;
namespace Cleanuparr.Infrastructure.Features.Files;
public class WindowsHardLinkFileService : IHardLinkFileService, IDisposable
public class WindowsHardLinkFileService : IWindowsHardLinkFileService, IDisposable
{
private readonly ILogger<WindowsHardLinkFileService> _logger;
private readonly ConcurrentDictionary<ulong, int> _fileIndexCounts = new();

View File

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

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",
@@ -224,7 +224,7 @@ public sealed class DownloadCleanerConfigTests
UnlinkedEnabled = true,
UnlinkedTargetCategory = "cleanuparr-unlinked",
UnlinkedCategories = ["movies"],
UnlinkedIgnoredRootDir = "/non/existent/directory"
UnlinkedIgnoredRootDirs = ["/non/existent/directory"]
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
@@ -241,7 +241,7 @@ public sealed class DownloadCleanerConfigTests
UnlinkedEnabled = true,
UnlinkedTargetCategory = "cleanuparr-unlinked",
UnlinkedCategories = ["movies"],
UnlinkedIgnoredRootDir = ""
UnlinkedIgnoredRootDirs = []
};
Should.NotThrow(() => config.Validate());
@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class ChangeUnlinkedIgnoredRootDirType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "unlinked_ignored_root_dir",
table: "download_cleaner_configs",
newName: "unlinked_ignored_root_dirs");
migrationBuilder.Sql("""
UPDATE download_cleaner_configs
SET unlinked_ignored_root_dirs = CASE
WHEN unlinked_ignored_root_dirs IS NULL OR unlinked_ignored_root_dirs = '' THEN '[]'
ELSE '["' || unlinked_ignored_root_dirs || '"]'
END
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
UPDATE download_cleaner_configs
SET unlinked_ignored_root_dirs = CASE
WHEN unlinked_ignored_root_dirs = '[]' OR unlinked_ignored_root_dirs IS NULL THEN ''
ELSE SUBSTR(unlinked_ignored_root_dirs, 3, LENGTH(unlinked_ignored_root_dirs) - 4)
END
""");
migrationBuilder.RenameColumn(
name: "unlinked_ignored_root_dirs",
table: "download_cleaner_configs",
newName: "unlinked_ignored_root_dir");
}
}
}

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")
@@ -176,10 +139,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("unlinked_enabled");
b.Property<string>("UnlinkedIgnoredRootDir")
b.PrimitiveCollection<string>("UnlinkedIgnoredRootDirs")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_ignored_root_dir");
.HasColumnName("unlinked_ignored_root_dirs");
b.Property<string>("UnlinkedTargetCategory")
.IsRequired()
@@ -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; }
@@ -32,7 +32,7 @@ public sealed record DownloadCleanerConfig : IJobConfig
public bool UnlinkedUseTag { get; set; }
public string UnlinkedIgnoredRootDir { get; set; } = string.Empty;
public List<string> UnlinkedIgnoredRootDirs { get; set; } = [];
public List<string> UnlinkedCategories { get; set; } = [];
@@ -87,9 +87,12 @@ public sealed record DownloadCleanerConfig : IJobConfig
throw new ValidationException("Empty unlinked category filter found");
}
if (!string.IsNullOrEmpty(UnlinkedIgnoredRootDir) && !Directory.Exists(UnlinkedIgnoredRootDir))
foreach (var dir in UnlinkedIgnoredRootDirs.Where(d => !string.IsNullOrEmpty(d)))
{
throw new ValidationException($"{UnlinkedIgnoredRootDir} root directory does not exist");
if (!Directory.Exists(dir))
{
throw new ValidationException($"{dir} root directory does not exist");
}
}
}
}

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

@@ -8,8 +8,8 @@ import { RadarrConfig } from "../../shared/models/radarr-config.model";
import { LidarrConfig } from "../../shared/models/lidarr-config.model";
import { ReadarrConfig } from "../../shared/models/readarr-config.model";
import { WhisparrConfig } from "../../shared/models/whisparr-config.model";
import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto } from "../../shared/models/download-client-config.model";
import { ArrInstance, CreateArrInstanceDto } from "../../shared/models/arr-config.model";
import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto, TestDownloadClientRequest, TestConnectionResult } from "../../shared/models/download-client-config.model";
import { ArrInstance, CreateArrInstanceDto, TestArrInstanceRequest } from "../../shared/models/arr-config.model";
import { GeneralConfig } from "../../shared/models/general-config.model";
import {
StallRule,
@@ -765,4 +765,84 @@ export class ConfigurationService {
})
);
}
// ===== CONNECTION TESTING =====
/**
* Test a Sonarr instance connection
*/
testSonarrInstance(request: TestArrInstanceRequest): Observable<TestConnectionResult> {
return this.http.post<TestConnectionResult>(this.ApplicationPathService.buildApiUrl('/configuration/sonarr/instances/test'), request).pipe(
catchError((error) => {
console.error("Error testing Sonarr instance:", error);
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
return throwError(() => new Error(errorMessage));
})
);
}
/**
* Test a Radarr instance connection
*/
testRadarrInstance(request: TestArrInstanceRequest): Observable<TestConnectionResult> {
return this.http.post<TestConnectionResult>(this.ApplicationPathService.buildApiUrl('/configuration/radarr/instances/test'), request).pipe(
catchError((error) => {
console.error("Error testing Radarr instance:", error);
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
return throwError(() => new Error(errorMessage));
})
);
}
/**
* Test a Lidarr instance connection
*/
testLidarrInstance(request: TestArrInstanceRequest): Observable<TestConnectionResult> {
return this.http.post<TestConnectionResult>(this.ApplicationPathService.buildApiUrl('/configuration/lidarr/instances/test'), request).pipe(
catchError((error) => {
console.error("Error testing Lidarr instance:", error);
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
return throwError(() => new Error(errorMessage));
})
);
}
/**
* Test a Readarr instance connection
*/
testReadarrInstance(request: TestArrInstanceRequest): Observable<TestConnectionResult> {
return this.http.post<TestConnectionResult>(this.ApplicationPathService.buildApiUrl('/configuration/readarr/instances/test'), request).pipe(
catchError((error) => {
console.error("Error testing Readarr instance:", error);
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
return throwError(() => new Error(errorMessage));
})
);
}
/**
* Test a Whisparr instance connection
*/
testWhisparrInstance(request: TestArrInstanceRequest): Observable<TestConnectionResult> {
return this.http.post<TestConnectionResult>(this.ApplicationPathService.buildApiUrl('/configuration/whisparr/instances/test'), request).pipe(
catchError((error) => {
console.error("Error testing Whisparr instance:", error);
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
return throwError(() => new Error(errorMessage));
})
);
}
/**
* Test a download client connection
*/
testDownloadClient(request: TestDownloadClientRequest): Observable<TestConnectionResult> {
return this.http.post<TestConnectionResult>(this.ApplicationPathService.buildApiUrl('/configuration/download_client/test'), request).pipe(
catchError((error) => {
console.error("Error testing download client:", error);
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
return throwError(() => new Error(errorMessage));
})
);
}
}

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

@@ -228,8 +228,8 @@
<div class="category-field">
<label>
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('maxSeedTime')"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('maxSeedTime')"
title="Click for documentation"></i>
Max Seed Time (hours)
</label>
@@ -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">
@@ -320,17 +333,20 @@
</div>
</div>
<!-- Ignored Root Directory -->
<!-- Ignored Root Directories -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('unlinkedIgnoredRootDir')"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('unlinkedIgnoredRootDirs')"
title="Click for documentation"></i>
Ignored Root Directory
Ignored Root Directories
</label>
<div class="field-input">
<input type="text" pInputText formControlName="unlinkedIgnoredRootDir" placeholder="/path/to/directory" />
<small class="form-helper-text">Root directory to ignore when checking for unlinked downloads (used for cross-seed)</small>
<app-mobile-autocomplete
formControlName="unlinkedIgnoredRootDirs"
placeholder="Add directory path"
></app-mobile-autocomplete>
<small class="form-helper-text">Root directories to ignore when checking for unlinked downloads (used for cross-seed)</small>
</div>
</div>

View File

@@ -150,7 +150,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
unlinkedEnabled: [{ value: false, disabled: true }],
unlinkedTargetCategory: [{ value: 'cleanuparr-unlinked', disabled: true }, [Validators.required]],
unlinkedUseTag: [{ value: false, disabled: true }],
unlinkedIgnoredRootDir: [{ value: '', disabled: true }],
unlinkedIgnoredRootDirs: [{ value: [], disabled: true }],
unlinkedCategories: [{ value: [], disabled: true }]
}, { validators: [this.validateUnlinkedCategories, this.validateAtLeastOneFeature] });
@@ -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 });
}
@@ -340,7 +341,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
unlinkedEnabled: config.unlinkedEnabled,
unlinkedTargetCategory: config.unlinkedTargetCategory,
unlinkedUseTag: config.unlinkedUseTag,
unlinkedIgnoredRootDir: config.unlinkedIgnoredRootDir,
unlinkedIgnoredRootDirs: config.unlinkedIgnoredRootDirs || [],
unlinkedCategories: config.unlinkedCategories || []
});
@@ -623,7 +624,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
unlinkedEnabled: formValues.unlinkedEnabled,
unlinkedTargetCategory: formValues.unlinkedTargetCategory,
unlinkedUseTag: formValues.unlinkedUseTag,
unlinkedIgnoredRootDir: formValues.unlinkedIgnoredRootDir,
unlinkedIgnoredRootDirs: formValues.unlinkedIgnoredRootDirs || [],
unlinkedCategories: formValues.unlinkedCategories || []
};
@@ -681,7 +682,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
unlinkedEnabled: false,
unlinkedTargetCategory: 'cleanuparr-unlinked',
unlinkedUseTag: false,
unlinkedIgnoredRootDir: '',
unlinkedIgnoredRootDirs: [],
unlinkedCategories: []
});
@@ -792,7 +793,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
private updateUnlinkedControlsState(enabled: boolean): void {
const targetCategoryControl = this.downloadCleanerForm.get('unlinkedTargetCategory');
const useTagControl = this.downloadCleanerForm.get('unlinkedUseTag');
const ignoredRootDirControl = this.downloadCleanerForm.get('unlinkedIgnoredRootDir');
const ignoredRootDirsControl = this.downloadCleanerForm.get('unlinkedIgnoredRootDirs');
const categoriesControl = this.downloadCleanerForm.get('unlinkedCategories');
// Disable emitting events during bulk changes
@@ -802,13 +803,13 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
// Enable all unlinked controls
targetCategoryControl?.enable(options);
useTagControl?.enable(options);
ignoredRootDirControl?.enable(options);
ignoredRootDirsControl?.enable(options);
categoriesControl?.enable(options);
} else {
// Disable all unlinked controls
targetCategoryControl?.disable(options);
useTagControl?.disable(options);
ignoredRootDirControl?.disable(options);
ignoredRootDirsControl?.disable(options);
categoriesControl?.disable(options);
}
}

View File

@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto } from '../../shared/models/download-client-config.model';
import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto, TestDownloadClientRequest, TestConnectionResult } from '../../shared/models/download-client-config.model';
import { ConfigurationService } from '../../core/services/configuration.service';
import { EMPTY, Observable, catchError, switchMap, tap, forkJoin, of } from 'rxjs';
@@ -11,6 +11,10 @@ export interface DownloadClientConfigState {
saving: boolean;
error: string | null;
pendingOperations: number;
testing: boolean;
testingClientId: string | null;
testError: string | null;
testResult: TestConnectionResult | null;
}
const initialState: DownloadClientConfigState = {
@@ -18,7 +22,11 @@ const initialState: DownloadClientConfigState = {
loading: false,
saving: false,
error: null,
pendingOperations: 0
pendingOperations: 0,
testing: false,
testingClientId: null,
testError: null,
testResult: null
};
@Injectable()
@@ -91,7 +99,41 @@ export class DownloadClientConfigStore extends signalStore(
resetError() {
patchState(store, { error: null });
},
/**
* Reset test state
*/
resetTestState() {
patchState(store, { testing: false, testingClientId: null, testError: null, testResult: null });
},
/**
* Test a download client connection
*/
testClient: rxMethod<{ request: TestDownloadClientRequest; clientId?: string }>(
(params$: Observable<{ request: TestDownloadClientRequest; clientId?: string }>) => params$.pipe(
tap(({ clientId }) => patchState(store, { testing: true, testingClientId: clientId || null, testError: null, testResult: null })),
switchMap(({ request }) => configService.testDownloadClient(request).pipe(
tap({
next: (result) => {
patchState(store, {
testing: false,
testResult: result,
testError: null
});
},
error: (error) => {
patchState(store, {
testing: false,
testError: error.message || 'Connection test failed'
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Batch create multiple clients
*/

View File

@@ -68,20 +68,31 @@
</div>
</div>
<div class="item-actions">
<button
pButton
type="button"
icon="pi pi-pencil"
<button
pButton
type="button"
icon="pi pi-play"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Test connection"
tooltipPosition="left"
[disabled]="testing() || downloadClientSaving()"
[loading]="testingClientId() === client.id"
(click)="testClientFromList(client)"
></button>
<button
pButton
type="button"
icon="pi pi-pencil"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Edit client"
tooltipPosition="left"
[disabled]="downloadClientSaving()"
(click)="openEditClientModal(client)"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
<button
pButton
type="button"
icon="pi pi-trash"
class="p-button-rounded p-button-text p-button-danger p-button-sm"
pTooltip="Delete client"
tooltipPosition="left"
@@ -248,17 +259,27 @@
<ng-template pTemplate="footer">
<div class="modal-footer">
<button
pButton
type="button"
label="Cancel"
<button
pButton
type="button"
label="Cancel"
class="p-button-text"
(click)="closeClientModal()"
></button>
<button
pButton
type="button"
label="Save"
<button
pButton
type="button"
label="Test"
icon="pi pi-play"
class="p-button-outlined ml-2"
[disabled]="clientForm.invalid || testing()"
[loading]="testing()"
(click)="testClientFromModal()"
></button>
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary ml-2"
[disabled]="clientForm.invalid || downloadClientSaving()"

View File

@@ -1,10 +1,10 @@
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from "@angular/forms";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { DownloadClientConfigStore } from "./download-client-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto } from "../../shared/models/download-client-config.model";
import { ClientConfig, CreateDownloadClientDto, TestDownloadClientRequest } from "../../shared/models/download-client-config.model";
import { DownloadClientType, DownloadClientTypeName } from "../../shared/models/enums";
import { DocumentationService } from "../../core/services/documentation.service";
@@ -76,6 +76,10 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
downloadClientLoading = this.downloadClientStore.loading;
downloadClientError = this.downloadClientStore.error;
downloadClientSaving = this.downloadClientStore.saving;
testing = this.downloadClientStore.testing;
testingClientId = this.downloadClientStore.testingClientId;
testError = this.downloadClientStore.testError;
testResult = this.downloadClientStore.testResult;
/**
* Check if component can be deactivated (navigation guard)
@@ -113,6 +117,22 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
.subscribe(() => {
this.onClientTypeChange();
});
// Setup effect to handle test results
effect(() => {
const testResult = this.testResult();
const testError = this.testError();
if (testResult) {
this.notificationService.showSuccess(testResult.message || 'Connection test successful');
this.downloadClientStore.resetTestState();
}
if (testError) {
this.notificationService.showError(testError);
this.downloadClientStore.resetTestState();
}
});
}
/**
@@ -372,4 +392,43 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
openFieldDocs(fieldName: string): void {
this.documentationService.openFieldDocumentation('download-client', fieldName);
}
/**
* Test client connection from the modal (new or editing)
*/
testClientFromModal(): void {
if (this.clientForm.invalid) {
this.markFormGroupTouched(this.clientForm);
this.notificationService.showError('Please fix the validation errors before testing');
return;
}
const formValue = this.clientForm.value;
const testRequest: TestDownloadClientRequest = {
typeName: formValue.typeName,
type: this.mapTypeNameToType(formValue.typeName),
host: formValue.host,
username: formValue.username,
password: formValue.password,
urlBase: formValue.urlBase,
};
this.downloadClientStore.testClient({ request: testRequest, clientId: this.editingClient?.id });
}
/**
* Test client connection from the list view (existing client)
*/
testClientFromList(client: ClientConfig): void {
const testRequest: TestDownloadClientRequest = {
typeName: client.typeName,
type: client.type,
host: client.host,
username: client.username,
password: client.password,
urlBase: client.urlBase,
};
this.downloadClientStore.testClient({ request: testRequest, clientId: client.id });
}
}

View File

@@ -4,7 +4,8 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { LidarrConfig } from '../../shared/models/lidarr-config.model';
import { ConfigurationService } from '../../core/services/configuration.service';
import { EMPTY, Observable, catchError, switchMap, tap, forkJoin, of } from 'rxjs';
import { ArrInstance, CreateArrInstanceDto } from '../../shared/models/arr-config.model';
import { ArrInstance, CreateArrInstanceDto, TestArrInstanceRequest } from '../../shared/models/arr-config.model';
import { TestConnectionResult } from '../../shared/models/download-client-config.model';
export interface LidarrConfigState {
config: LidarrConfig | null;
@@ -12,6 +13,10 @@ export interface LidarrConfigState {
saving: boolean;
error: string | null;
instanceOperations: number;
testing: boolean;
testingInstanceId: string | null;
testError: string | null;
testResult: TestConnectionResult | null;
}
const initialState: LidarrConfigState = {
@@ -19,7 +24,11 @@ const initialState: LidarrConfigState = {
loading: false,
saving: false,
error: null,
instanceOperations: 0
instanceOperations: 0,
testing: false,
testingInstanceId: null,
testError: null,
testResult: null
};
@Injectable()
@@ -123,6 +132,40 @@ export class LidarrConfigStore extends signalStore(
patchState(store, { error: null });
},
/**
* Reset test state
*/
resetTestState() {
patchState(store, { testing: false, testingInstanceId: null, testError: null, testResult: null });
},
/**
* Test a Lidarr instance connection
*/
testInstance: rxMethod<{ request: TestArrInstanceRequest; instanceId?: string }>(
(params$: Observable<{ request: TestArrInstanceRequest; instanceId?: string }>) => params$.pipe(
tap(({ instanceId }) => patchState(store, { testing: true, testingInstanceId: instanceId || null, testError: null, testResult: null })),
switchMap(({ request }) => configService.testLidarrInstance(request).pipe(
tap({
next: (result) => {
patchState(store, {
testing: false,
testResult: result,
testError: null
});
},
error: (error) => {
patchState(store, {
testing: false,
testError: error.message || 'Connection test failed'
});
}
}),
catchError(() => EMPTY)
))
)
),
// ===== INSTANCE MANAGEMENT =====
/**

View File

@@ -111,20 +111,31 @@
</div>
</div>
<div class="item-actions">
<button
pButton
type="button"
icon="pi pi-pencil"
<button
pButton
type="button"
icon="pi pi-play"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Test connection"
tooltipPosition="left"
[disabled]="testing() || lidarrSaving()"
[loading]="testingInstanceId() === instance.id"
(click)="testInstanceFromList(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-pencil"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Edit instance"
tooltipPosition="left"
[disabled]="lidarrSaving()"
(click)="openEditInstanceModal(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
<button
pButton
type="button"
icon="pi pi-trash"
class="p-button-rounded p-button-text p-button-danger p-button-sm"
pTooltip="Delete instance"
tooltipPosition="left"
@@ -216,19 +227,29 @@
<ng-template pTemplate="footer">
<div class="modal-footer">
<button
pButton
type="button"
label="Cancel"
<button
pButton
type="button"
label="Cancel"
class="p-button-text"
(click)="closeInstanceModal()"
></button>
<button
pButton
type="button"
label="Save"
<button
pButton
type="button"
label="Test"
icon="pi pi-play"
class="p-button-outlined ml-2"
[disabled]="instanceForm.invalid || testing()"
[loading]="testing()"
(click)="testInstanceFromModal()"
></button>
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary"
class="p-button-primary ml-2"
[disabled]="instanceForm.invalid || lidarrSaving()"
[loading]="lidarrSaving()"
(click)="saveInstance()"

View File

@@ -5,7 +5,7 @@ import { Subject, takeUntil } from "rxjs";
import { LidarrConfigStore } from "./lidarr-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { LidarrConfig } from "../../shared/models/lidarr-config.model";
import { CreateArrInstanceDto, ArrInstance } from "../../shared/models/arr-config.model";
import { CreateArrInstanceDto, ArrInstance, TestArrInstanceRequest } from "../../shared/models/arr-config.model";
// PrimeNG Components
import { CardModule } from "primeng/card";
@@ -74,6 +74,10 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
lidarrLoading = this.lidarrStore.loading;
lidarrError = this.lidarrStore.error;
lidarrSaving = this.lidarrStore.saving;
testing = this.lidarrStore.testing;
testingInstanceId = this.lidarrStore.testingInstanceId;
testError = this.lidarrStore.testError;
testResult = this.lidarrStore.testResult;
/**
* Check if component can be deactivated (navigation guard)
@@ -112,6 +116,22 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
.subscribe(() => {
this.hasGlobalChanges = this.globalFormValuesChanged();
});
// Setup effect to handle test results
effect(() => {
const testResult = this.testResult();
const testError = this.testError();
if (testResult) {
this.notificationService.showSuccess(testResult.message || 'Connection test successful');
this.lidarrStore.resetTestState();
}
if (testError) {
this.notificationService.showError(testError);
this.lidarrStore.resetTestState();
}
});
}
/**
@@ -418,4 +438,34 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat
get modalTitle(): string {
return this.modalMode === 'add' ? 'Add Lidarr Instance' : 'Edit Lidarr Instance';
}
/**
* Test instance connection from the modal (new or editing)
*/
testInstanceFromModal(): void {
if (this.instanceForm.invalid) {
this.markFormGroupTouched(this.instanceForm);
this.notificationService.showError('Please fix the validation errors before testing');
return;
}
const testRequest: TestArrInstanceRequest = {
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
};
this.lidarrStore.testInstance({ request: testRequest, instanceId: this.editingInstance?.id });
}
/**
* Test instance connection from the list view (existing instance)
*/
testInstanceFromList(instance: ArrInstance): void {
const testRequest: TestArrInstanceRequest = {
url: instance.url,
apiKey: instance.apiKey,
};
this.lidarrStore.testInstance({ request: testRequest, instanceId: instance.id });
}
}

View File

@@ -211,25 +211,17 @@ export class NotificationProviderConfigStore extends signalStore(
},
error: (error) => {
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
patchState(store, {
testing: false,
testError: errorMessage, // Test errors should NOT trigger "Not connected" state
testResult: {
success: false,
message: errorMessage
}
patchState(store, {
testing: false,
testError: errorMessage // Test errors should NOT trigger "Not connected" state
});
}
}),
catchError((error) => {
const errorMessage = ErrorHandlerUtil.extractErrorMessage(error);
patchState(store, {
testing: false,
testError: errorMessage, // Test errors should NOT trigger "Not connected" state
testResult: {
success: false,
message: errorMessage
}
patchState(store, {
testing: false,
testError: errorMessage // Test errors should NOT trigger "Not connected" state
});
return EMPTY;
})

View File

@@ -130,16 +130,11 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
}
});
// Setup effect to react to test results
// Setup effect to react to test results (HTTP 200 = success)
effect(() => {
const result = this.testResult();
if (result) {
if (result.success) {
this.notificationService.showSuccess(result.message || "Test notification sent successfully");
} else {
// Error handling is already done in the test error effect above
// This just handles the success case
}
this.notificationService.showSuccess(result.message || "Test notification sent successfully");
}
});
}

View File

@@ -4,7 +4,8 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { RadarrConfig } from '../../shared/models/radarr-config.model';
import { ConfigurationService } from '../../core/services/configuration.service';
import { EMPTY, Observable, catchError, switchMap, tap, forkJoin, of } from 'rxjs';
import { ArrInstance, CreateArrInstanceDto } from '../../shared/models/arr-config.model';
import { ArrInstance, CreateArrInstanceDto, TestArrInstanceRequest } from '../../shared/models/arr-config.model';
import { TestConnectionResult } from '../../shared/models/download-client-config.model';
export interface RadarrConfigState {
config: RadarrConfig | null;
@@ -12,6 +13,10 @@ export interface RadarrConfigState {
saving: boolean;
error: string | null;
instanceOperations: number;
testing: boolean;
testingInstanceId: string | null;
testError: string | null;
testResult: TestConnectionResult | null;
}
const initialState: RadarrConfigState = {
@@ -19,7 +24,11 @@ const initialState: RadarrConfigState = {
loading: false,
saving: false,
error: null,
instanceOperations: 0
instanceOperations: 0,
testing: false,
testingInstanceId: null,
testError: null,
testResult: null
};
@Injectable()
@@ -123,6 +132,40 @@ export class RadarrConfigStore extends signalStore(
patchState(store, { error: null });
},
/**
* Reset test state
*/
resetTestState() {
patchState(store, { testing: false, testingInstanceId: null, testError: null, testResult: null });
},
/**
* Test a Radarr instance connection
*/
testInstance: rxMethod<{ request: TestArrInstanceRequest; instanceId?: string }>(
(params$: Observable<{ request: TestArrInstanceRequest; instanceId?: string }>) => params$.pipe(
tap(({ instanceId }) => patchState(store, { testing: true, testingInstanceId: instanceId || null, testError: null, testResult: null })),
switchMap(({ request }) => configService.testRadarrInstance(request).pipe(
tap({
next: (result) => {
patchState(store, {
testing: false,
testResult: result,
testError: null
});
},
error: (error) => {
patchState(store, {
testing: false,
testError: error.message || 'Connection test failed'
});
}
}),
catchError(() => EMPTY)
))
)
),
// ===== INSTANCE MANAGEMENT =====
/**

View File

@@ -111,20 +111,31 @@
</div>
</div>
<div class="item-actions">
<button
pButton
type="button"
icon="pi pi-pencil"
<button
pButton
type="button"
icon="pi pi-play"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Test connection"
tooltipPosition="left"
[disabled]="testing() || radarrSaving()"
[loading]="testingInstanceId() === instance.id"
(click)="testInstanceFromList(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-pencil"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Edit instance"
tooltipPosition="left"
[disabled]="radarrSaving()"
(click)="openEditInstanceModal(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
<button
pButton
type="button"
icon="pi pi-trash"
class="p-button-rounded p-button-text p-button-danger p-button-sm"
pTooltip="Delete instance"
tooltipPosition="left"
@@ -216,17 +227,27 @@
<ng-template pTemplate="footer">
<div class="modal-footer">
<button
pButton
type="button"
label="Cancel"
<button
pButton
type="button"
label="Cancel"
class="p-button-text"
(click)="closeInstanceModal()"
></button>
<button
pButton
type="button"
label="Save"
<button
pButton
type="button"
label="Test"
icon="pi pi-play"
class="p-button-outlined ml-2"
[disabled]="instanceForm.invalid || testing()"
[loading]="testing()"
(click)="testInstanceFromModal()"
></button>
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary ml-2"
[disabled]="instanceForm.invalid || radarrSaving()"

View File

@@ -5,7 +5,7 @@ import { Subject, takeUntil } from "rxjs";
import { RadarrConfigStore } from "./radarr-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { RadarrConfig } from "../../shared/models/radarr-config.model";
import { CreateArrInstanceDto, ArrInstance } from "../../shared/models/arr-config.model";
import { CreateArrInstanceDto, ArrInstance, TestArrInstanceRequest } from "../../shared/models/arr-config.model";
// PrimeNG Components
import { CardModule } from "primeng/card";
@@ -74,6 +74,10 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
radarrLoading = this.radarrStore.loading;
radarrError = this.radarrStore.error;
radarrSaving = this.radarrStore.saving;
testing = this.radarrStore.testing;
testingInstanceId = this.radarrStore.testingInstanceId;
testError = this.radarrStore.testError;
testResult = this.radarrStore.testResult;
/**
* Check if component can be deactivated (navigation guard)
@@ -112,6 +116,22 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
.subscribe(() => {
this.hasGlobalChanges = this.globalFormValuesChanged();
});
// Setup effect to handle test results
effect(() => {
const testResult = this.testResult();
const testError = this.testError();
if (testResult) {
this.notificationService.showSuccess(testResult.message || 'Connection test successful');
this.radarrStore.resetTestState();
}
if (testError) {
this.notificationService.showError(testError);
this.radarrStore.resetTestState();
}
});
}
/**
@@ -418,4 +438,34 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat
get modalTitle(): string {
return this.modalMode === 'add' ? 'Add Radarr Instance' : 'Edit Radarr Instance';
}
/**
* Test instance connection from the modal (new or editing)
*/
testInstanceFromModal(): void {
if (this.instanceForm.invalid) {
this.markFormGroupTouched(this.instanceForm);
this.notificationService.showError('Please fix the validation errors before testing');
return;
}
const testRequest: TestArrInstanceRequest = {
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
};
this.radarrStore.testInstance({ request: testRequest, instanceId: this.editingInstance?.id });
}
/**
* Test instance connection from the list view (existing instance)
*/
testInstanceFromList(instance: ArrInstance): void {
const testRequest: TestArrInstanceRequest = {
url: instance.url,
apiKey: instance.apiKey,
};
this.radarrStore.testInstance({ request: testRequest, instanceId: instance.id });
}
}

View File

@@ -4,7 +4,8 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { ReadarrConfig } from '../../shared/models/readarr-config.model';
import { ConfigurationService } from '../../core/services/configuration.service';
import { EMPTY, Observable, catchError, switchMap, tap, forkJoin, of } from 'rxjs';
import { ArrInstance, CreateArrInstanceDto } from '../../shared/models/arr-config.model';
import { ArrInstance, CreateArrInstanceDto, TestArrInstanceRequest } from '../../shared/models/arr-config.model';
import { TestConnectionResult } from '../../shared/models/download-client-config.model';
export interface ReadarrConfigState {
config: ReadarrConfig | null;
@@ -12,6 +13,10 @@ export interface ReadarrConfigState {
saving: boolean;
error: string | null;
instanceOperations: number;
testing: boolean;
testingInstanceId: string | null;
testError: string | null;
testResult: TestConnectionResult | null;
}
const initialState: ReadarrConfigState = {
@@ -19,7 +24,11 @@ const initialState: ReadarrConfigState = {
loading: false,
saving: false,
error: null,
instanceOperations: 0
instanceOperations: 0,
testing: false,
testingInstanceId: null,
testError: null,
testResult: null
};
@Injectable()
@@ -123,6 +132,40 @@ export class ReadarrConfigStore extends signalStore(
patchState(store, { error: null });
},
/**
* Reset test state
*/
resetTestState() {
patchState(store, { testing: false, testingInstanceId: null, testError: null, testResult: null });
},
/**
* Test a Readarr instance connection
*/
testInstance: rxMethod<{ request: TestArrInstanceRequest; instanceId?: string }>(
(params$: Observable<{ request: TestArrInstanceRequest; instanceId?: string }>) => params$.pipe(
tap(({ instanceId }) => patchState(store, { testing: true, testingInstanceId: instanceId || null, testError: null, testResult: null })),
switchMap(({ request }) => configService.testReadarrInstance(request).pipe(
tap({
next: (result) => {
patchState(store, {
testing: false,
testResult: result,
testError: null
});
},
error: (error) => {
patchState(store, {
testing: false,
testError: error.message || 'Connection test failed'
});
}
}),
catchError(() => EMPTY)
))
)
),
// ===== INSTANCE MANAGEMENT =====
/**

View File

@@ -111,20 +111,31 @@
</div>
</div>
<div class="item-actions">
<button
pButton
type="button"
icon="pi pi-pencil"
<button
pButton
type="button"
icon="pi pi-play"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Test connection"
tooltipPosition="left"
[disabled]="testing() || readarrSaving()"
[loading]="testingInstanceId() === instance.id"
(click)="testInstanceFromList(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-pencil"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Edit instance"
tooltipPosition="left"
[disabled]="readarrSaving()"
(click)="openEditInstanceModal(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
<button
pButton
type="button"
icon="pi pi-trash"
class="p-button-rounded p-button-text p-button-danger p-button-sm"
pTooltip="Delete instance"
tooltipPosition="left"
@@ -216,17 +227,27 @@
<ng-template pTemplate="footer">
<div class="modal-footer">
<button
pButton
type="button"
label="Cancel"
<button
pButton
type="button"
label="Cancel"
class="p-button-text"
(click)="closeInstanceModal()"
></button>
<button
pButton
type="button"
label="Save"
<button
pButton
type="button"
label="Test"
icon="pi pi-play"
class="p-button-outlined ml-2"
[disabled]="instanceForm.invalid || testing()"
[loading]="testing()"
(click)="testInstanceFromModal()"
></button>
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary ml-2"
[disabled]="instanceForm.invalid || readarrSaving()"

View File

@@ -5,7 +5,7 @@ import { Subject, takeUntil } from "rxjs";
import { ReadarrConfigStore } from "./readarr-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { ReadarrConfig } from "../../shared/models/readarr-config.model";
import { CreateArrInstanceDto, ArrInstance } from "../../shared/models/arr-config.model";
import { CreateArrInstanceDto, ArrInstance, TestArrInstanceRequest } from "../../shared/models/arr-config.model";
// PrimeNG Components
import { CardModule } from "primeng/card";
@@ -74,6 +74,10 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
readarrLoading = this.readarrStore.loading;
readarrError = this.readarrStore.error;
readarrSaving = this.readarrStore.saving;
testing = this.readarrStore.testing;
testingInstanceId = this.readarrStore.testingInstanceId;
testError = this.readarrStore.testError;
testResult = this.readarrStore.testResult;
/**
* Check if component can be deactivated (navigation guard)
@@ -112,6 +116,22 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
.subscribe(() => {
this.hasGlobalChanges = this.globalFormValuesChanged();
});
// Setup effect to handle test results
effect(() => {
const testResult = this.testResult();
const testError = this.testError();
if (testResult) {
this.notificationService.showSuccess(testResult.message || 'Connection test successful');
this.readarrStore.resetTestState();
}
if (testError) {
this.notificationService.showError(testError);
this.readarrStore.resetTestState();
}
});
}
/**
@@ -418,4 +438,34 @@ export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactiva
const control = parentControl.get(controlName);
return control ? control.dirty && control.hasError(errorName) : false;
}
/**
* Test instance connection from the modal (new or editing)
*/
testInstanceFromModal(): void {
if (this.instanceForm.invalid) {
this.markFormGroupTouched(this.instanceForm);
this.notificationService.showError('Please fix the validation errors before testing');
return;
}
const testRequest: TestArrInstanceRequest = {
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
};
this.readarrStore.testInstance({ request: testRequest, instanceId: this.editingInstance?.id });
}
/**
* Test instance connection from the list view (existing instance)
*/
testInstanceFromList(instance: ArrInstance): void {
const testRequest: TestArrInstanceRequest = {
url: instance.url,
apiKey: instance.apiKey,
};
this.readarrStore.testInstance({ request: testRequest, instanceId: instance.id });
}
}

View File

@@ -4,7 +4,8 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { SonarrConfig } from '../../shared/models/sonarr-config.model';
import { ConfigurationService } from '../../core/services/configuration.service';
import { EMPTY, Observable, catchError, switchMap, tap, forkJoin, of } from 'rxjs';
import { ArrInstance, CreateArrInstanceDto } from '../../shared/models/arr-config.model';
import { ArrInstance, CreateArrInstanceDto, TestArrInstanceRequest } from '../../shared/models/arr-config.model';
import { TestConnectionResult } from '../../shared/models/download-client-config.model';
export interface SonarrConfigState {
config: SonarrConfig | null;
@@ -12,6 +13,10 @@ export interface SonarrConfigState {
saving: boolean;
error: string | null;
instanceOperations: number;
testing: boolean;
testingInstanceId: string | null;
testError: string | null;
testResult: TestConnectionResult | null;
}
const initialState: SonarrConfigState = {
@@ -19,7 +24,11 @@ const initialState: SonarrConfigState = {
loading: false,
saving: false,
error: null,
instanceOperations: 0
instanceOperations: 0,
testing: false,
testingInstanceId: null,
testError: null,
testResult: null
};
@Injectable()
@@ -97,6 +106,40 @@ export class SonarrConfigStore extends signalStore(
patchState(store, { error: null });
},
/**
* Reset test state
*/
resetTestState() {
patchState(store, { testing: false, testingInstanceId: null, testError: null, testResult: null });
},
/**
* Test a Sonarr instance connection
*/
testInstance: rxMethod<{ request: TestArrInstanceRequest; instanceId?: string }>(
(params$: Observable<{ request: TestArrInstanceRequest; instanceId?: string }>) => params$.pipe(
tap(({ instanceId }) => patchState(store, { testing: true, testingInstanceId: instanceId || null, testError: null, testResult: null })),
switchMap(({ request }) => configService.testSonarrInstance(request).pipe(
tap({
next: (result) => {
patchState(store, {
testing: false,
testResult: result,
testError: null
});
},
error: (error) => {
patchState(store, {
testing: false,
testError: error.message || 'Connection test failed'
});
}
}),
catchError(() => EMPTY)
))
)
),
// ===== INSTANCE MANAGEMENT =====
/**

View File

@@ -111,20 +111,31 @@
</div>
</div>
<div class="item-actions">
<button
pButton
type="button"
icon="pi pi-pencil"
<button
pButton
type="button"
icon="pi pi-play"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Test connection"
tooltipPosition="left"
[disabled]="testing() || sonarrSaving()"
[loading]="testingInstanceId() === instance.id"
(click)="testInstanceFromList(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-pencil"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Edit instance"
tooltipPosition="left"
[disabled]="sonarrSaving()"
(click)="openEditInstanceModal(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
<button
pButton
type="button"
icon="pi pi-trash"
class="p-button-rounded p-button-text p-button-danger p-button-sm"
pTooltip="Delete instance"
tooltipPosition="left"
@@ -216,17 +227,27 @@
<ng-template pTemplate="footer">
<div class="modal-footer">
<button
pButton
type="button"
label="Cancel"
<button
pButton
type="button"
label="Cancel"
class="p-button-text"
(click)="closeInstanceModal()"
></button>
<button
pButton
type="button"
label="Save"
<button
pButton
type="button"
label="Test"
icon="pi pi-play"
class="p-button-outlined ml-2"
[disabled]="instanceForm.invalid || testing()"
[loading]="testing()"
(click)="testInstanceFromModal()"
></button>
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary ml-2"
[disabled]="instanceForm.invalid || sonarrSaving()"

View File

@@ -5,7 +5,7 @@ import { Subject, takeUntil } from "rxjs";
import { SonarrConfigStore } from "./sonarr-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { SonarrConfig } from "../../shared/models/sonarr-config.model";
import { CreateArrInstanceDto, ArrInstance } from "../../shared/models/arr-config.model";
import { CreateArrInstanceDto, ArrInstance, TestArrInstanceRequest } from "../../shared/models/arr-config.model";
// PrimeNG Components
import { CardModule } from "primeng/card";
@@ -74,6 +74,10 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
sonarrLoading = this.sonarrStore.loading;
sonarrError = this.sonarrStore.error;
sonarrSaving = this.sonarrStore.saving;
testing = this.sonarrStore.testing;
testingInstanceId = this.sonarrStore.testingInstanceId;
testError = this.sonarrStore.testError;
testResult = this.sonarrStore.testResult;
/**
* Check if component can be deactivated (navigation guard)
@@ -112,6 +116,22 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
.subscribe(() => {
this.hasGlobalChanges = this.globalFormValuesChanged();
});
// Setup effect to handle test results
effect(() => {
const testResult = this.testResult();
const testError = this.testError();
if (testResult) {
this.notificationService.showSuccess(testResult.message || 'Connection test successful');
this.sonarrStore.resetTestState();
}
if (testError) {
this.notificationService.showError(testError);
this.sonarrStore.resetTestState();
}
});
}
/**
@@ -418,4 +438,34 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
get modalTitle(): string {
return this.modalMode === 'add' ? 'Add Sonarr Instance' : 'Edit Sonarr Instance';
}
/**
* Test instance connection from the modal (new or editing)
*/
testInstanceFromModal(): void {
if (this.instanceForm.invalid) {
this.markFormGroupTouched(this.instanceForm);
this.notificationService.showError('Please fix the validation errors before testing');
return;
}
const testRequest: TestArrInstanceRequest = {
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
};
this.sonarrStore.testInstance({ request: testRequest, instanceId: this.editingInstance?.id });
}
/**
* Test instance connection from the list view (existing instance)
*/
testInstanceFromList(instance: ArrInstance): void {
const testRequest: TestArrInstanceRequest = {
url: instance.url,
apiKey: instance.apiKey,
};
this.sonarrStore.testInstance({ request: testRequest, instanceId: instance.id });
}
}

View File

@@ -4,7 +4,8 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { WhisparrConfig } from '../../shared/models/whisparr-config.model';
import { ConfigurationService } from '../../core/services/configuration.service';
import { EMPTY, Observable, catchError, switchMap, tap, forkJoin, of } from 'rxjs';
import { ArrInstance, CreateArrInstanceDto } from '../../shared/models/arr-config.model';
import { ArrInstance, CreateArrInstanceDto, TestArrInstanceRequest } from '../../shared/models/arr-config.model';
import { TestConnectionResult } from '../../shared/models/download-client-config.model';
export interface WhisparrConfigState {
config: WhisparrConfig | null;
@@ -12,6 +13,10 @@ export interface WhisparrConfigState {
saving: boolean;
error: string | null;
instanceOperations: number;
testing: boolean;
testingInstanceId: string | null;
testError: string | null;
testResult: TestConnectionResult | null;
}
const initialState: WhisparrConfigState = {
@@ -19,7 +24,11 @@ const initialState: WhisparrConfigState = {
loading: false,
saving: false,
error: null,
instanceOperations: 0
instanceOperations: 0,
testing: false,
testingInstanceId: null,
testError: null,
testResult: null
};
@Injectable()
@@ -123,6 +132,40 @@ export class WhisparrConfigStore extends signalStore(
patchState(store, { error: null });
},
/**
* Reset test state
*/
resetTestState() {
patchState(store, { testing: false, testingInstanceId: null, testError: null, testResult: null });
},
/**
* Test a Whisparr instance connection
*/
testInstance: rxMethod<{ request: TestArrInstanceRequest; instanceId?: string }>(
(params$: Observable<{ request: TestArrInstanceRequest; instanceId?: string }>) => params$.pipe(
tap(({ instanceId }) => patchState(store, { testing: true, testingInstanceId: instanceId || null, testError: null, testResult: null })),
switchMap(({ request }) => configService.testWhisparrInstance(request).pipe(
tap({
next: (result) => {
patchState(store, {
testing: false,
testResult: result,
testError: null
});
},
error: (error) => {
patchState(store, {
testing: false,
testError: error.message || 'Connection test failed'
});
}
}),
catchError(() => EMPTY)
))
)
),
// ===== INSTANCE MANAGEMENT =====
/**

View File

@@ -111,20 +111,31 @@
</div>
</div>
<div class="item-actions">
<button
pButton
type="button"
icon="pi pi-pencil"
<button
pButton
type="button"
icon="pi pi-play"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Test connection"
tooltipPosition="left"
[disabled]="testing() || whisparrSaving()"
[loading]="testingInstanceId() === instance.id"
(click)="testInstanceFromList(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-pencil"
class="p-button-rounded p-button-text p-button-sm mr-1"
pTooltip="Edit instance"
tooltipPosition="left"
[disabled]="whisparrSaving()"
(click)="openEditInstanceModal(instance)"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
<button
pButton
type="button"
icon="pi pi-trash"
class="p-button-rounded p-button-text p-button-danger p-button-sm"
pTooltip="Delete instance"
tooltipPosition="left"
@@ -216,17 +227,27 @@
<ng-template pTemplate="footer">
<div class="modal-footer">
<button
pButton
type="button"
label="Cancel"
<button
pButton
type="button"
label="Cancel"
class="p-button-text"
(click)="closeInstanceModal()"
></button>
<button
pButton
type="button"
label="Save"
<button
pButton
type="button"
label="Test"
icon="pi pi-play"
class="p-button-outlined ml-2"
[disabled]="instanceForm.invalid || testing()"
[loading]="testing()"
(click)="testInstanceFromModal()"
></button>
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary ml-2"
[disabled]="instanceForm.invalid || whisparrSaving()"

View File

@@ -5,7 +5,7 @@ import { Subject, takeUntil } from "rxjs";
import { WhisparrConfigStore } from "./whisparr-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { WhisparrConfig } from "../../shared/models/whisparr-config.model";
import { CreateArrInstanceDto, ArrInstance } from "../../shared/models/arr-config.model";
import { CreateArrInstanceDto, ArrInstance, TestArrInstanceRequest } from "../../shared/models/arr-config.model";
// PrimeNG Components
import { CardModule } from "primeng/card";
@@ -74,6 +74,10 @@ export class WhisparrSettingsComponent implements OnDestroy, CanComponentDeactiv
whisparrLoading = this.whisparrStore.loading;
whisparrError = this.whisparrStore.error;
whisparrSaving = this.whisparrStore.saving;
testing = this.whisparrStore.testing;
testingInstanceId = this.whisparrStore.testingInstanceId;
testError = this.whisparrStore.testError;
testResult = this.whisparrStore.testResult;
/**
* Check if component can be deactivated (navigation guard)
@@ -112,6 +116,22 @@ export class WhisparrSettingsComponent implements OnDestroy, CanComponentDeactiv
.subscribe(() => {
this.hasGlobalChanges = this.globalFormValuesChanged();
});
// Setup effect to handle test results
effect(() => {
const testResult = this.testResult();
const testError = this.testError();
if (testResult) {
this.notificationService.showSuccess(testResult.message || 'Connection test successful');
this.whisparrStore.resetTestState();
}
if (testError) {
this.notificationService.showError(testError);
this.whisparrStore.resetTestState();
}
});
}
/**
@@ -413,4 +433,34 @@ export class WhisparrSettingsComponent implements OnDestroy, CanComponentDeactiv
get modalTitle(): string {
return this.modalMode === 'add' ? 'Add Whisparr Instance' : 'Edit Whisparr Instance';
}
/**
* Test instance connection from the modal (new or editing)
*/
testInstanceFromModal(): void {
if (this.instanceForm.invalid) {
this.markFormGroupTouched(this.instanceForm);
this.notificationService.showError('Please fix the validation errors before testing');
return;
}
const testRequest: TestArrInstanceRequest = {
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
};
this.whisparrStore.testInstance({ request: testRequest, instanceId: this.editingInstance?.id });
}
/**
* Test instance connection from the list view (existing instance)
*/
testInstanceFromList(instance: ArrInstance): void {
const testRequest: TestArrInstanceRequest = {
url: instance.url,
apiKey: instance.apiKey,
};
this.whisparrStore.testInstance({ request: testRequest, instanceId: instance.id });
}
}

View File

@@ -18,3 +18,11 @@ export interface CreateArrInstanceDto {
url: string;
apiKey: string;
}
/**
* Request for testing an Arr instance connection
*/
export interface TestArrInstanceRequest {
url: string;
apiKey: string;
}

View File

@@ -13,7 +13,7 @@ export interface DownloadCleanerConfig {
unlinkedEnabled: boolean;
unlinkedTargetCategory: string;
unlinkedUseTag: boolean;
unlinkedIgnoredRootDir: string;
unlinkedIgnoredRootDirs: string[];
unlinkedCategories: string[];
}
@@ -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
};
}
@@ -54,6 +56,6 @@ export const defaultDownloadCleanerConfig: DownloadCleanerConfig = {
unlinkedEnabled: false,
unlinkedTargetCategory: 'cleanuparr-unlinked',
unlinkedUseTag: false,
unlinkedIgnoredRootDir: '',
unlinkedIgnoredRootDirs: [],
unlinkedCategories: []
};

View File

@@ -124,3 +124,23 @@ export interface ClientConfigUpdateDto extends ClientConfig {
*/
password: string;
}
/**
* Request for testing a download client connection
*/
export interface TestDownloadClientRequest {
typeName: DownloadClientTypeName;
type: DownloadClientType;
host?: string;
username?: string;
password?: string;
urlBase?: string;
}
/**
* Result of testing a connection (HTTP 200 = success)
*/
export interface TestConnectionResult {
message: string;
responseTime?: number;
}

View File

@@ -61,7 +61,5 @@ export interface AppriseConfiguration {
}
export interface TestNotificationResult {
success: boolean;
message: string;
error?: string;
}

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}>