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
120 changed files with 4129 additions and 1453 deletions

View File

@@ -23,6 +23,10 @@ ARG PACKAGES_PAT
WORKDIR /app
EXPOSE 11011
# Copy solution and project files first for better layer caching
# COPY backend/*.sln ./backend/
# COPY backend/*/*.csproj ./backend/*/
# Copy source code
COPY backend/ ./backend/
@@ -44,21 +48,13 @@ RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim
# Install required packages for user management, timezone support, and Python for Apprise CLI
# Install required packages for user management and timezone support
RUN apt-get update && apt-get install -y \
curl \
tzdata \
gosu \
python3 \
python3-venv \
&& rm -rf /var/lib/apt/lists/*
# Create virtual environment and install Apprise CLI
ENV VIRTUAL_ENV=/opt/apprise-venv
RUN python3 -m venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
RUN pip install --no-cache-dir apprise==1.9.6
ENV PUID=1000 \
PGID=1000 \
UMASK=022 \

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

@@ -12,8 +12,6 @@ public static class NotificationsDI
services
.AddScoped<INotifiarrProxy, NotifiarrProxy>()
.AddScoped<IAppriseProxy, AppriseProxy>()
.AddScoped<IAppriseCliProxy, AppriseCliProxy>()
.AddSingleton<IAppriseCliDetector, AppriseCliDetector>()
.AddScoped<INtfyProxy, NtfyProxy>()
.AddScoped<IPushoverProxy, PushoverProxy>()
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()

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

@@ -1,18 +1,10 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record CreateAppriseProviderRequest : CreateNotificationProviderRequestBase
{
public AppriseMode Mode { get; init; } = AppriseMode.Api;
// API mode fields
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
// CLI mode fields
public string? ServiceUrls { get; init; }
}

View File

@@ -1,18 +1,10 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestAppriseProviderRequest
{
public AppriseMode Mode { get; init; } = AppriseMode.Api;
// API mode fields
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
// CLI mode fields
public string? ServiceUrls { get; init; }
}

View File

@@ -1,18 +1,10 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record UpdateAppriseProviderRequest : UpdateNotificationProviderRequestBase
{
public AppriseMode Mode { get; init; } = AppriseMode.Api;
// API mode fields
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
// CLI mode fields
public string? ServiceUrls { get; init; }
}

View File

@@ -1,15 +1,15 @@
using System.Net;
using Cleanuparr.Api.Features.Notifications.Contracts.Requests;
using Cleanuparr.Api.Features.Notifications.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.Notifications.Controllers;
@@ -21,20 +21,17 @@ public sealed class NotificationProvidersController : ControllerBase
private readonly DataContext _dataContext;
private readonly INotificationConfigurationService _notificationConfigurationService;
private readonly NotificationService _notificationService;
private readonly IAppriseCliDetector _appriseCliDetector;
public NotificationProvidersController(
ILogger<NotificationProvidersController> logger,
DataContext dataContext,
INotificationConfigurationService notificationConfigurationService,
NotificationService notificationService,
IAppriseCliDetector appriseCliDetector)
NotificationService notificationService)
{
_logger = logger;
_dataContext = dataContext;
_notificationConfigurationService = notificationConfigurationService;
_notificationService = notificationService;
_appriseCliDetector = appriseCliDetector;
}
[HttpGet]
@@ -89,18 +86,6 @@ public sealed class NotificationProvidersController : ControllerBase
}
}
[HttpGet("apprise/cli-status")]
public async Task<IActionResult> GetAppriseCliStatus()
{
string? version = await _appriseCliDetector.GetAppriseVersionAsync();
return Ok(new
{
Available = version is not null,
Version = version
});
}
[HttpPost("notifiarr")]
public async Task<IActionResult> CreateNotifiarrProvider([FromBody] CreateNotifiarrProviderRequest newProvider)
{
@@ -177,11 +162,9 @@ public sealed class NotificationProvidersController : ControllerBase
var appriseConfig = new AppriseConfig
{
Mode = newProvider.Mode,
Url = newProvider.Url,
Key = newProvider.Key,
Tags = newProvider.Tags,
ServiceUrls = newProvider.ServiceUrls
Tags = newProvider.Tags
};
appriseConfig.Validate();
@@ -399,11 +382,9 @@ public sealed class NotificationProvidersController : ControllerBase
var appriseConfig = new AppriseConfig
{
Mode = updatedProvider.Mode,
Url = updatedProvider.Url,
Key = updatedProvider.Key,
Tags = updatedProvider.Tags,
ServiceUrls = updatedProvider.ServiceUrls
Tags = updatedProvider.Tags
};
if (existingProvider.AppriseConfiguration != null)
@@ -605,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}" });
}
}
@@ -621,11 +602,9 @@ public sealed class NotificationProvidersController : ControllerBase
{
var appriseConfig = new AppriseConfig
{
Mode = testRequest.Mode,
Url = testRequest.Url,
Key = testRequest.Key,
Tags = testRequest.Tags,
ServiceUrls = testRequest.ServiceUrls
Tags = testRequest.Tags
};
appriseConfig.Validate();
@@ -648,16 +627,12 @@ public sealed class NotificationProvidersController : ControllerBase
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
}
catch (AppriseException exception)
{
return StatusCode((int)HttpStatusCode.InternalServerError, exception.Message);
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}" });
}
}
@@ -698,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}" });
}
}
@@ -924,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

@@ -1,7 +0,0 @@
namespace Cleanuparr.Domain.Enums;
public enum AppriseMode
{
Api,
Cli
}

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

@@ -1,34 +0,0 @@
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Apprise;
public class AppriseCliDetectorTests
{
private readonly AppriseCliDetector _detector;
public AppriseCliDetectorTests()
{
_detector = new AppriseCliDetector(Substitute.For<ILogger<AppriseCliDetector>>());
}
[Fact]
public void Constructor_CreatesInstance()
{
// Act
var detector = new AppriseCliDetector(Substitute.For<ILogger<AppriseCliDetector>>());
// Assert
Assert.NotNull(detector);
}
[Fact]
public async Task GetAppriseVersionAsync_DoesNotThrow()
{
// Act & Assert - should handle missing CLI gracefully without throwing
var exception = await Record.ExceptionAsync(() => _detector.GetAppriseVersionAsync());
Assert.Null(exception);
}
}

View File

@@ -1,73 +0,0 @@
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Apprise;
public class AppriseCliProxyTests
{
private readonly AppriseCliProxy _proxy;
public AppriseCliProxyTests()
{
_proxy = new AppriseCliProxy();
}
private static ApprisePayload CreatePayload(string title = "Test Title", string body = "Test Body")
{
return new ApprisePayload
{
Title = title,
Body = body,
Type = "info"
};
}
private static AppriseConfig CreateConfig(string? serviceUrls = null)
{
return new AppriseConfig
{
ServiceUrls = serviceUrls
};
}
#region SendNotification Validation Tests
[Fact]
public async Task SendNotification_WhenServiceUrlsIsNull_ThrowsAppriseException()
{
// Arrange
var config = CreateConfig(null);
// Act & Assert
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
_proxy.SendNotification(CreatePayload(), config));
Assert.Contains("No service URLs configured", ex.Message);
}
[Fact]
public async Task SendNotification_WhenServiceUrlsIsEmpty_ThrowsAppriseException()
{
// Arrange
var config = CreateConfig("");
// Act & Assert
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
_proxy.SendNotification(CreatePayload(), config));
Assert.Contains("No service URLs configured", ex.Message);
}
[Fact]
public async Task SendNotification_WhenServiceUrlsIsWhitespace_ThrowsAppriseException()
{
// Arrange
var config = CreateConfig(" \n \n ");
// Act & Assert
var ex = await Assert.ThrowsAsync<AppriseException>(() =>
_proxy.SendNotification(CreatePayload(), config));
Assert.Contains("No service URLs configured", ex.Message);
}
#endregion
}

View File

@@ -9,19 +9,16 @@ namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
public class AppriseProviderTests
{
private readonly Mock<IAppriseProxy> _apiProxyMock;
private readonly Mock<IAppriseCliProxy> _cliProxyMock;
private readonly Mock<IAppriseProxy> _proxyMock;
private readonly AppriseConfig _config;
private readonly AppriseProvider _provider;
public AppriseProviderTests()
{
_apiProxyMock = new Mock<IAppriseProxy>();
_cliProxyMock = new Mock<IAppriseCliProxy>();
_proxyMock = new Mock<IAppriseProxy>();
_config = new AppriseConfig
{
Id = Guid.NewGuid(),
Mode = AppriseMode.Api,
Url = "http://apprise.example.com",
Key = "testkey",
Tags = "tag1,tag2"
@@ -31,8 +28,7 @@ public class AppriseProviderTests
"TestApprise",
NotificationProviderType.Apprise,
_config,
_apiProxyMock.Object,
_cliProxyMock.Object);
_proxyMock.Object);
}
#region Constructor Tests
@@ -62,7 +58,7 @@ public class AppriseProviderTests
var context = CreateTestContext();
ApprisePayload? capturedPayload = null;
_apiProxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
@@ -85,7 +81,7 @@ public class AppriseProviderTests
ApprisePayload? capturedPayload = null;
_apiProxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
@@ -116,7 +112,7 @@ public class AppriseProviderTests
ApprisePayload? capturedPayload = null;
_apiProxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
@@ -135,7 +131,7 @@ public class AppriseProviderTests
var context = CreateTestContext();
ApprisePayload? capturedPayload = null;
_apiProxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
@@ -153,7 +149,7 @@ public class AppriseProviderTests
// Arrange
var context = CreateTestContext();
_apiProxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.ThrowsAsync(new Exception("Proxy error"));
// Act & Assert
@@ -175,7 +171,7 @@ public class AppriseProviderTests
ApprisePayload? capturedPayload = null;
_apiProxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
_proxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), _config))
.Callback<ApprisePayload, AppriseConfig>((payload, config) => capturedPayload = payload)
.Returns(Task.CompletedTask);
@@ -187,40 +183,6 @@ public class AppriseProviderTests
Assert.Contains("Test Description", capturedPayload.Body);
}
[Fact]
public async Task SendNotificationAsync_CliMode_CallsCliProxy()
{
// Arrange
var cliConfig = new AppriseConfig
{
Id = Guid.NewGuid(),
Mode = AppriseMode.Cli,
ServiceUrls = "discord://webhook_id/token"
};
var apiProxyMock = new Mock<IAppriseProxy>();
var cliProxyMock = new Mock<IAppriseCliProxy>();
var provider = new AppriseProvider(
"TestAppriseCli",
NotificationProviderType.Apprise,
cliConfig,
apiProxyMock.Object,
cliProxyMock.Object);
var context = CreateTestContext();
cliProxyMock.Setup(p => p.SendNotification(It.IsAny<ApprisePayload>(), cliConfig))
.Returns(Task.CompletedTask);
// Act
await provider.SendNotificationAsync(context);
// Assert
cliProxyMock.Verify(p => p.SendNotification(It.IsAny<ApprisePayload>(), cliConfig), Times.Once);
apiProxyMock.Verify(p => p.SendNotification(It.IsAny<ApprisePayload>(), It.IsAny<AppriseConfig>()), Times.Never);
}
#endregion
#region Helper Methods

View File

@@ -15,7 +15,6 @@ namespace Cleanuparr.Infrastructure.Tests.Features.Notifications;
public class NotificationProviderFactoryTests
{
private readonly Mock<IAppriseProxy> _appriseProxyMock;
private readonly Mock<IAppriseCliProxy> _appriseCliProxyMock;
private readonly Mock<INtfyProxy> _ntfyProxyMock;
private readonly Mock<INotifiarrProxy> _notifiarrProxyMock;
private readonly Mock<IPushoverProxy> _pushoverProxyMock;
@@ -25,14 +24,12 @@ public class NotificationProviderFactoryTests
public NotificationProviderFactoryTests()
{
_appriseProxyMock = new Mock<IAppriseProxy>();
_appriseCliProxyMock = new Mock<IAppriseCliProxy>();
_ntfyProxyMock = new Mock<INtfyProxy>();
_notifiarrProxyMock = new Mock<INotifiarrProxy>();
_pushoverProxyMock = new Mock<IPushoverProxy>();
var services = new ServiceCollection();
services.AddSingleton(_appriseProxyMock.Object);
services.AddSingleton(_appriseCliProxyMock.Object);
services.AddSingleton(_ntfyProxyMock.Object);
services.AddSingleton(_notifiarrProxyMock.Object);
services.AddSingleton(_pushoverProxyMock.Object);

View File

@@ -7,7 +7,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliWrap" Version="3.10.0" />
<PackageReference Include="FLM.QBittorrent" Version="1.0.2" />
<PackageReference Include="FLM.Transmission" Version="1.0.3" />
<PackageReference Include="Mapster" Version="7.4.0" />

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

@@ -1,38 +0,0 @@
using System.Text;
using CliWrap;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise;
public sealed class AppriseCliDetector : IAppriseCliDetector
{
private readonly ILogger<AppriseCliDetector> _logger;
private static readonly TimeSpan DetectionTimeout = TimeSpan.FromSeconds(5);
public AppriseCliDetector(ILogger<AppriseCliDetector> logger)
{
_logger = logger;
}
public async Task<string?> GetAppriseVersionAsync()
{
using var cts = new CancellationTokenSource(DetectionTimeout);
try
{
StringBuilder version = new();
_ = await Cli.Wrap("apprise")
.WithArguments("--version")
.WithStandardOutputPipe(PipeTarget.ToStringBuilder(version))
.ExecuteAsync(cts.Token);
return version.ToString().Split('\n').FirstOrDefault();
}
catch (Exception exception)
{
_logger.LogError(exception, "Failed to get apprise version");
return null;
}
}
}

View File

@@ -1,69 +0,0 @@
using System.Text;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using CliWrap;
namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise;
public sealed class AppriseCliProxy : IAppriseCliProxy
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
public async Task SendNotification(ApprisePayload payload, AppriseConfig config)
{
var serviceUrls = config.ServiceUrls?
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(u => u.Trim())
.Where(u => !string.IsNullOrEmpty(u))
.ToArray();
if (serviceUrls == null || serviceUrls.Length == 0)
{
throw new AppriseException("No service URLs configured");
}
var args = new List<string> { "--verbose" };
if (!string.IsNullOrEmpty(payload.Title))
{
args.AddRange(["--title", payload.Title]);
}
args.AddRange(["--body", payload.Body, "--notification-type", payload.Type]);
args.AddRange(serviceUrls);
await ExecuteAppriseAsync(args);
}
private static async Task ExecuteAppriseAsync(IEnumerable<string> arguments)
{
using var cts = new CancellationTokenSource(DefaultTimeout);
StringBuilder message = new();
try
{
CommandResult result = await Cli.Wrap("apprise")
.WithArguments(arguments)
.WithStandardOutputPipe(PipeTarget.ToStringBuilder(message))
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(message))
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(cts.Token);
if (!result.IsSuccess)
{
throw new AppriseException($"Apprise CLI failed with: {message}");
}
}
catch (AppriseException)
{
throw;
}
catch (OperationCanceledException)
{
throw new AppriseException($"Apprise CLI timed out after {DefaultTimeout.TotalSeconds} seconds.");
}
catch (Exception exception)
{
throw new AppriseException("Apprise CLI failed", exception);
}
}
}

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using System.Text;
@@ -7,33 +8,22 @@ namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise;
public sealed class AppriseProvider : NotificationProviderBase<AppriseConfig>
{
private readonly IAppriseProxy _apiProxy;
private readonly IAppriseCliProxy _cliProxy;
private readonly IAppriseProxy _proxy;
public AppriseProvider(
string name,
NotificationProviderType type,
AppriseConfig config,
IAppriseProxy apiProxy,
IAppriseCliProxy cliProxy
IAppriseProxy proxy
) : base(name, type, config)
{
_apiProxy = apiProxy;
_cliProxy = cliProxy;
_proxy = proxy;
}
public override async Task SendNotificationAsync(NotificationContext context)
{
ApprisePayload payload = BuildPayload(context);
if (Config.Mode is AppriseMode.Cli)
{
await _cliProxy.SendNotification(payload, Config);
}
else
{
await _apiProxy.SendNotification(payload, Config);
}
await _proxy.SendNotification(payload, Config);
}
private ApprisePayload BuildPayload(NotificationContext context)
@@ -61,7 +51,7 @@ public sealed class AppriseProvider : NotificationProviderBase<AppriseConfig>
var body = new StringBuilder();
body.AppendLine(context.Description);
body.AppendLine();
foreach ((string key, string value) in context.Data)
{
body.AppendLine($"{key}: {value}");

View File

@@ -1,6 +0,0 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise;
public interface IAppriseCliDetector
{
Task<string?> GetAppriseVersionAsync();
}

View File

@@ -1,8 +0,0 @@
using Cleanuparr.Persistence.Models.Configuration.Notification;
namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise;
public interface IAppriseCliProxy
{
Task SendNotification(ApprisePayload payload, AppriseConfig config);
}

View File

@@ -41,10 +41,9 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory
private INotificationProvider CreateAppriseProvider(NotificationProviderDto config)
{
var appriseConfig = (AppriseConfig)config.Configuration;
var apiProxy = _serviceProvider.GetRequiredService<IAppriseProxy>();
var cliProxy = _serviceProvider.GetRequiredService<IAppriseCliProxy>();
return new AppriseProvider(config.Name, config.Type, appriseConfig, apiProxy, cliProxy);
var proxy = _serviceProvider.GetRequiredService<IAppriseProxy>();
return new AppriseProvider(config.Name, config.Type, appriseConfig, proxy);
}
private INotificationProvider CreateNtfyProvider(NotificationProviderDto config)

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

@@ -1,4 +1,3 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Shouldly;
using Xunit;
@@ -143,7 +142,7 @@ public sealed class AppriseConfigTests
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Apprise server URL is required for API mode");
exception.Message.ShouldBe("Apprise server URL is required");
}
[Fact]
@@ -172,7 +171,7 @@ public sealed class AppriseConfigTests
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Apprise configuration key is required for API mode");
exception.Message.ShouldBe("Apprise configuration key is required");
}
[Fact]
@@ -201,104 +200,4 @@ public sealed class AppriseConfigTests
}
#endregion
#region CLI Mode Tests
[Fact]
public void IsValid_CliMode_WithValidServiceUrls_ReturnsTrue()
{
var config = new AppriseConfig
{
Mode = AppriseMode.Cli,
ServiceUrls = "discord://webhook_id/webhook_token"
};
config.IsValid().ShouldBeTrue();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void IsValid_CliMode_WithEmptyServiceUrls_ReturnsFalse(string? serviceUrls)
{
var config = new AppriseConfig
{
Mode = AppriseMode.Cli,
ServiceUrls = serviceUrls
};
config.IsValid().ShouldBeFalse();
}
[Fact]
public void Validate_CliMode_WithValidServiceUrls_DoesNotThrow()
{
var config = new AppriseConfig
{
Mode = AppriseMode.Cli,
ServiceUrls = "discord://webhook_id/webhook_token\nslack://token_a/token_b/token_c"
};
Should.NotThrow(() => config.Validate());
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_CliMode_WithEmptyServiceUrls_ThrowsValidationException(string? serviceUrls)
{
var config = new AppriseConfig
{
Mode = AppriseMode.Cli,
ServiceUrls = serviceUrls
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("At least one service URL is required for CLI mode");
}
[Fact]
public void Validate_CliMode_WithValidUrlAndWhitespaceLines_DoesNotThrow()
{
// url1 is valid content, whitespace lines should be filtered out
var config = new AppriseConfig
{
Mode = AppriseMode.Cli,
ServiceUrls = "discord://webhook_id/token\n \n "
};
Should.NotThrow(() => config.Validate());
}
[Fact]
public void IsValid_CliMode_IgnoresApiModeFields()
{
var config = new AppriseConfig
{
Mode = AppriseMode.Cli,
Url = string.Empty, // Would be invalid in API mode
Key = string.Empty, // Would be invalid in API mode
ServiceUrls = "discord://webhook_id/webhook_token"
};
config.IsValid().ShouldBeTrue();
}
[Fact]
public void IsValid_ApiMode_IgnoresCliModeFields()
{
var config = new AppriseConfig
{
Mode = AppriseMode.Api,
Url = "https://apprise.example.com",
Key = "my-key",
ServiceUrls = null // Would be invalid in CLI mode
};
config.IsValid().ShouldBeTrue();
}
#endregion
}

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

@@ -1,40 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddAppriseCliMode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "mode",
table: "apprise_configs",
type: "TEXT",
nullable: false,
defaultValue: "api");
migrationBuilder.AddColumn<string>(
name: "service_urls",
table: "apprise_configs",
type: "TEXT",
maxLength: 4000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "mode",
table: "apprise_configs");
migrationBuilder.DropColumn(
name: "service_urls",
table: "apprise_configs");
}
}
}

View File

@@ -12,8 +12,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Cleanuparr.Persistence.Migrations.Data
{
[DbContext(typeof(DataContext))]
[Migration("20251213201344_AddAppriseCliMode")]
partial class AddAppriseCliMode
[Migration("20251216204347_AddDeleteSourceFilesToCleanCategory")]
partial class AddDeleteSourceFilesToCleanCategory
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -115,6 +115,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
.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");
@@ -482,20 +486,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("key");
b.Property<string>("Mode")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("mode");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<string>("ServiceUrls")
.HasMaxLength(4000)
.HasColumnType("TEXT")
.HasColumnName("service_urls");
b.Property<string>("Tags")
.HasMaxLength(255)
.HasColumnType("TEXT")

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")
@@ -479,20 +483,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("key");
b.Property<string>("Mode")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("mode");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<string>("ServiceUrls")
.HasMaxLength(4000)
.HasColumnType("TEXT")
.HasColumnName("service_urls");
b.Property<string>("Tags")
.HasMaxLength(255)
.HasColumnType("TEXT")
@@ -969,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

@@ -1,7 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Diagnostics.CodeAnalysis;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
@@ -12,36 +10,23 @@ public sealed record AppriseConfig : IConfig
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; init; } = Guid.NewGuid();
[Required]
[ExcludeFromCodeCoverage]
public Guid NotificationConfigId { get; init; }
public NotificationConfig NotificationConfig { get; init; } = null!;
/// <summary>
/// The mode of operation: Api (external apprise-api container) or Cli (bundled apprise CLI)
/// </summary>
public AppriseMode Mode { get; init; } = AppriseMode.Api;
// API mode fields
[Required]
[MaxLength(500)]
public string Url { get; init; } = string.Empty;
[Required]
[MaxLength(255)]
public string Key { get; init; } = string.Empty;
[MaxLength(255)]
public string? Tags { get; init; }
// CLI mode fields
/// <summary>
/// Apprise service URLs for CLI mode (one per line).
/// Example: discord://webhook_id/webhook_token
/// </summary>
[MaxLength(4000)]
public string? ServiceUrls { get; init; }
[NotMapped]
public Uri? Uri
{
@@ -57,56 +42,33 @@ public sealed record AppriseConfig : IConfig
}
}
}
public bool IsValid()
{
return Mode switch
{
AppriseMode.Api => Uri != null && !string.IsNullOrWhiteSpace(Key),
AppriseMode.Cli => !string.IsNullOrWhiteSpace(ServiceUrls),
_ => false
};
return Uri != null &&
!string.IsNullOrWhiteSpace(Key);
}
public void Validate()
{
if (Mode is AppriseMode.Api)
{
ValidateApiMode();
return;
}
ValidateCliMode();
}
private void ValidateApiMode()
{
if (string.IsNullOrWhiteSpace(Url))
{
throw new ValidationException("Apprise server URL is required for API mode");
throw new ValidationException("Apprise server URL is required");
}
if (Uri is null)
if (Uri == null)
{
throw new ValidationException("Apprise server URL must be a valid HTTP or HTTPS URL");
}
if (string.IsNullOrWhiteSpace(Key))
{
throw new ValidationException("Apprise configuration key is required for API mode");
throw new ValidationException("Apprise configuration key is required");
}
if (Key.Length < 2)
{
throw new ValidationException("Apprise configuration key must be at least 2 characters long");
}
}
private void ValidateCliMode()
{
if (string.IsNullOrWhiteSpace(ServiceUrls))
{
throw new ValidationException("At least one service URL is required for CLI mode");
}
}
}

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',
@@ -119,11 +120,9 @@ export class DocumentationService {
'notifiarr.channelId': 'channel-id'
},
'notifications/apprise': {
'apprise.mode': 'mode',
'apprise.url': 'url',
'apprise.key': 'key',
'apprise.tags': 'tags',
'apprise.serviceUrls': 'service-urls'
'apprise.tags': 'tags'
},
'notifications/ntfy': {
'ntfy.serverUrl': 'server-url',

View File

@@ -7,16 +7,11 @@ import {
NotificationProviderDto,
TestNotificationResult
} from '../../shared/models/notification-provider.model';
import { AppriseMode, NotificationProviderType } from '../../shared/models/enums';
import { NotificationProviderType } from '../../shared/models/enums';
import { NtfyAuthenticationType } from '../../shared/models/ntfy-authentication-type.enum';
import { NtfyPriority } from '../../shared/models/ntfy-priority.enum';
import { PushoverPriority } from '../../shared/models/pushover-priority.enum';
export interface AppriseCliStatus {
available: boolean;
version: string | null;
}
// Provider-specific interfaces
export interface CreateNotifiarrProviderRequest {
name: string;
@@ -58,13 +53,9 @@ export interface CreateAppriseProviderRequest {
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
mode: AppriseMode;
// API mode fields
url: string;
key: string;
tags: string;
// CLI mode fields
serviceUrls: string;
}
export interface UpdateAppriseProviderRequest {
@@ -76,23 +67,15 @@ export interface UpdateAppriseProviderRequest {
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
mode: AppriseMode;
// API mode fields
url: string;
key: string;
tags: string;
// CLI mode fields
serviceUrls: string;
}
export interface TestAppriseProviderRequest {
mode: AppriseMode;
// API mode fields
url: string;
key: string;
tags: string;
// CLI mode fields
serviceUrls: string;
}
export interface CreateNtfyProviderRequest {
@@ -208,13 +191,6 @@ export class NotificationProviderService {
return this.http.get<NotificationProvidersConfig>(this.baseUrl);
}
/**
* Get Apprise CLI availability status
*/
getAppriseCliStatus(): Observable<AppriseCliStatus> {
return this.http.get<AppriseCliStatus>(`${this.baseUrl}/apprise/cli-status`);
}
/**
* Create a new Notifiarr provider
*/

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

@@ -10,145 +10,67 @@
>
<!-- Provider-specific configuration goes here -->
<div slot="provider-config">
<!-- Mode Selection -->
<!-- Apprise Server URL -->
<div class="field">
<label for="mode">
<label for="full-url">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('apprise.mode')"
(click)="openFieldDocs('apprise.url')"
></i>
Mode *
Apprise Server URL *
</label>
<p-select
id="mode"
[options]="modeOptions"
[formControl]="modeControl"
optionLabel="label"
optionValue="value"
<input
id="full-url"
type="url"
pInputText
[formControl]="urlControl"
placeholder="http://localhost:8000"
class="w-full"
[showClear]="false"
></p-select>
<small class="form-helper-text">
API mode requires an external Apprise container. CLI mode uses the Apprise CLI directly, but requires installation for non-Docker users.
</small>
/>
<small *ngIf="hasFieldError(urlControl, 'required')" class="form-error-text">URL is required</small>
<small *ngIf="hasFieldError(urlControl, 'invalidUri')" class="form-error-text">Must be a valid URL</small>
<small *ngIf="hasFieldError(urlControl, 'invalidProtocol')" class="form-error-text">Must use http or https protocol</small>
<small class="form-helper-text">The URL of your Apprise server where notifications will be sent.</small>
</div>
<!-- CLI Availability Loading -->
<div *ngIf="modeControl.value === 'Cli' && checkingCliAvailability" class="cli-detection-loading mb-3">
<p-progressSpinner styleClass="w-1rem h-1rem"></p-progressSpinner>
<span>Detecting local Apprise version...</span>
<!-- Configuration Key -->
<div class="field">
<label for="key">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('apprise.key')"
></i>
Configuration Key *
</label>
<input id="key" type="text" pInputText [formControl]="keyControl" placeholder="my-config-key" class="w-full" />
<small *ngIf="hasFieldError(keyControl, 'required')" class="form-error-text">Configuration key is required</small>
<small *ngIf="hasFieldError(keyControl, 'minlength')" class="form-error-text">Key must be at least 2 characters</small>
<small class="form-helper-text">The key that identifies your Apprise configuration on the server.</small>
</div>
<!-- CLI Availability Warning -->
<p-message
*ngIf="modeControl.value === 'Cli' && !checkingCliAvailability && !cliAvailable"
severity="warn"
styleClass="w-full mb-3"
>
Apprise CLI not detected.&nbsp;
<a href="https://github.com/caronc/apprise#installation" target="_blank" rel="noopener">
Installation Guide
</a>
</p-message>
<!-- CLI Available Info -->
<p-message
*ngIf="modeControl.value === 'Cli' && !checkingCliAvailability && cliAvailable && cliVersion"
severity="success"
styleClass="w-full mb-3"
[text]="'Apprise CLI detected: ' + cliVersion"
></p-message>
<!-- API Mode Fields -->
<ng-container *ngIf="modeControl.value === 'Api'">
<!-- Apprise Server URL -->
<div class="field">
<label for="full-url">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('apprise.url')"
></i>
Apprise Server URL *
</label>
<input
id="full-url"
type="url"
pInputText
[formControl]="urlControl"
placeholder="http://localhost:8000"
class="w-full"
/>
<small *ngIf="hasFieldError(urlControl, 'required')" class="form-error-text">URL is required</small>
<small *ngIf="hasFieldError(urlControl, 'invalidUri')" class="form-error-text">Must be a valid URL</small>
<small *ngIf="hasFieldError(urlControl, 'invalidProtocol')" class="form-error-text">Must use http or https protocol</small>
<small class="form-helper-text">The URL of your Apprise server where notifications will be sent.</small>
</div>
<!-- Configuration Key -->
<div class="field">
<label for="key">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('apprise.key')"
></i>
Configuration Key *
</label>
<input id="key" type="text" pInputText [formControl]="keyControl" placeholder="my-config-key" class="w-full" />
<small *ngIf="hasFieldError(keyControl, 'required')" class="form-error-text">Configuration key is required</small>
<small *ngIf="hasFieldError(keyControl, 'minlength')" class="form-error-text">Key must be at least 2 characters</small>
<small class="form-helper-text">The key that identifies your Apprise configuration on the server.</small>
</div>
<!-- Tags -->
<div class="field">
<label for="tags">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('apprise.tags')"
></i>
Tags (Optional)
</label>
<input
id="tags"
type="text"
pInputText
[formControl]="tagsControl"
placeholder="tag1,tag2 or tag3 tag4"
class="w-full"
/>
<small class="form-helper-text"
>Optional tags to filter notifications. Use comma (,) to OR tags and space ( ) to AND them.</small
>
</div>
</ng-container>
<!-- CLI Mode Fields -->
<ng-container *ngIf="modeControl.value === 'Cli'">
<!-- Service URLs -->
<div class="field">
<label for="serviceUrls">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('apprise.serviceUrls')"
></i>
Service URLs *
</label>
<app-mobile-autocomplete
[formControl]="serviceUrlsControl"
placeholder="Add service URL and press Enter"
></app-mobile-autocomplete>
<small class="form-helper-text">
Add Apprise service URLs. Example: discord://webhook_id/token.
<a href="https://github.com/caronc/apprise/wiki#notification-services" target="_blank" rel="noopener">
View all supported services
</a>
</small>
</div>
</ng-container>
<!-- Tags -->
<div class="field">
<label for="tags">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('apprise.tags')"
></i>
Tags (Optional)
</label>
<input
id="tags"
type="text"
pInputText
[formControl]="tagsControl"
placeholder="tag1,tag2 or tag3 tag4"
class="w-full"
/>
<small class="form-helper-text"
>Optional tags to filter notifications. Use comma (,) to OR tags and space ( ) to AND them.</small
>
</div>
</div>
</app-notification-provider-base>

View File

@@ -1,10 +1,2 @@
/* Apprise Provider Modal Styles */
@use '../../../styles/settings-shared.scss';
.cli-detection-loading {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-color-secondary);
font-size: 0.875rem;
}

View File

@@ -1,19 +1,12 @@
import { Component, Input, Output, EventEmitter, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, OnChanges, SimpleChanges, inject } from '@angular/core';
import { FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import { Subscription } from 'rxjs';
import { CommonModule } from '@angular/common';
import { InputTextModule } from 'primeng/inputtext';
import { MobileAutocompleteComponent } from '../../../../shared/components/mobile-autocomplete/mobile-autocomplete.component';
import { SelectModule } from 'primeng/select';
import { Message } from 'primeng/message';
import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { AppriseFormData, BaseProviderFormData } from '../../models/provider-modal.model';
import { DocumentationService } from '../../../../core/services/documentation.service';
import { NotificationProviderService } from '../../../../core/services/notification-provider.service';
import { NotificationProviderDto } from '../../../../shared/models/notification-provider.model';
import { NotificationProviderBaseComponent } from '../base/notification-provider-base.component';
import { UrlValidators } from '../../../../core/validators/url.validator';
import { AppriseMode } from '../../../../shared/models/enums';
@Component({
selector: 'app-apprise-provider',
@@ -22,16 +15,12 @@ import { AppriseMode } from '../../../../shared/models/enums';
CommonModule,
ReactiveFormsModule,
InputTextModule,
SelectModule,
Message,
ProgressSpinnerModule,
MobileAutocompleteComponent,
NotificationProviderBaseComponent
],
templateUrl: './apprise-provider.component.html',
styleUrls: ['./apprise-provider.component.scss']
})
export class AppriseProviderComponent implements OnInit, OnChanges, OnDestroy {
export class AppriseProviderComponent implements OnInit, OnChanges {
@Input() visible = false;
@Input() editingProvider: NotificationProviderDto | null = null;
@Input() saving = false;
@@ -41,32 +30,11 @@ export class AppriseProviderComponent implements OnInit, OnChanges, OnDestroy {
@Output() cancel = new EventEmitter<void>();
@Output() test = new EventEmitter<AppriseFormData>();
private documentationService = inject(DocumentationService);
private notificationProviderService = inject(NotificationProviderService);
// Mode selection
modeControl = new FormControl<AppriseMode>(AppriseMode.Api, { nonNullable: true });
modeOptions = [
{ label: 'API', value: AppriseMode.Api },
{ label: 'CLI', value: AppriseMode.Cli }
];
// CLI availability status
checkingCliAvailability = false;
cliAvailable = false;
cliVersion: string | null = null;
// API mode form controls
// Provider-specific form controls
urlControl = new FormControl('', [Validators.required, UrlValidators.httpUrl]);
keyControl = new FormControl('', [Validators.required, Validators.minLength(2)]);
tagsControl = new FormControl(''); // Optional field
// CLI mode form controls
serviceUrlsControl = new FormControl<string[]>([]);
// Subscription for mode changes
private modeSubscription?: Subscription;
private cliCheckedThisSession = false;
private documentationService = inject(DocumentationService);
/**
* Exposed for template to open documentation for apprise fields
@@ -76,16 +44,7 @@ export class AppriseProviderComponent implements OnInit, OnChanges, OnDestroy {
}
ngOnInit(): void {
// Subscribe to mode changes to check CLI availability when switching to CLI mode
this.modeSubscription = this.modeControl.valueChanges.subscribe((mode) => {
if (mode === AppriseMode.Cli && !this.cliCheckedThisSession) {
this.checkCliAvailability();
}
});
}
ngOnDestroy(): void {
this.modeSubscription?.unsubscribe();
// Initialize component but don't populate yet - wait for ngOnChanges
}
ngOnChanges(changes: SimpleChanges): void {
@@ -98,106 +57,41 @@ export class AppriseProviderComponent implements OnInit, OnChanges, OnDestroy {
this.resetProviderFields();
}
}
// When modal becomes visible, reset the CLI check flag and check if already in CLI mode
if (changes['visible'] && this.visible) {
this.cliCheckedThisSession = false;
// Only check CLI availability if mode is already CLI (editing existing CLI provider)
if (this.modeControl.value === AppriseMode.Cli) {
this.checkCliAvailability();
}
}
}
private checkCliAvailability(): void {
this.checkingCliAvailability = true;
this.cliCheckedThisSession = true;
this.notificationProviderService.getAppriseCliStatus().subscribe({
next: (status) => {
this.cliAvailable = status.available;
this.cliVersion = status.version;
this.checkingCliAvailability = false;
},
error: () => {
this.cliAvailable = false;
this.cliVersion = null;
this.checkingCliAvailability = false;
}
});
}
private populateProviderFields(): void {
if (this.editingProvider) {
const config = this.editingProvider.configuration as any;
this.modeControl.setValue(config?.mode || AppriseMode.Api);
// API mode fields
this.urlControl.setValue(config?.url || '');
this.keyControl.setValue(config?.key || '');
this.tagsControl.setValue(config?.tags || '');
// CLI mode fields - convert newline-separated string to array
const serviceUrlsString = config?.serviceUrls || '';
const serviceUrlsArray = serviceUrlsString
.split('\n')
.map((url: string) => url.trim())
.filter((url: string) => url.length > 0);
this.serviceUrlsControl.setValue(serviceUrlsArray);
}
}
private resetProviderFields(): void {
this.modeControl.setValue(AppriseMode.Api);
this.urlControl.setValue('');
this.keyControl.setValue('');
this.tagsControl.setValue('');
this.serviceUrlsControl.setValue([]);
}
protected hasFieldError(control: FormControl, errorType: string): boolean {
return !!(control && control.errors?.[errorType] && (control.dirty || control.touched));
}
private isFormValid(): boolean {
const mode = this.modeControl.value;
if (mode === AppriseMode.Api) {
return this.urlControl.valid && this.keyControl.valid;
onSave(baseData: BaseProviderFormData): void {
if (this.urlControl.valid && this.keyControl.valid) {
const appriseData: AppriseFormData = {
...baseData,
url: this.urlControl.value || '',
key: this.keyControl.value || '',
tags: this.tagsControl.value || ''
};
this.save.emit(appriseData);
} else {
// CLI mode requires at least one service URL
const serviceUrls = this.serviceUrlsControl.value || [];
return serviceUrls.length > 0;
}
}
private markFieldsTouched(): void {
const mode = this.modeControl.value;
if (mode === AppriseMode.Api) {
// Mark provider-specific fields as touched to show validation errors
this.urlControl.markAsTouched();
this.keyControl.markAsTouched();
} else {
this.serviceUrlsControl.markAsTouched();
}
}
private buildFormData(baseData: BaseProviderFormData): AppriseFormData {
// Convert array to newline-separated string for backend
const serviceUrlsArray = this.serviceUrlsControl.value || [];
const serviceUrlsString = serviceUrlsArray.join('\n');
return {
...baseData,
mode: this.modeControl.value,
url: this.urlControl.value || '',
key: this.keyControl.value || '',
tags: this.tagsControl.value || '',
serviceUrls: serviceUrlsString
};
}
onSave(baseData: BaseProviderFormData): void {
if (this.isFormValid()) {
this.save.emit(this.buildFormData(baseData));
} else {
this.markFieldsTouched();
}
}
@@ -206,10 +100,20 @@ export class AppriseProviderComponent implements OnInit, OnChanges, OnDestroy {
}
onTest(baseData: BaseProviderFormData): void {
if (this.isFormValid()) {
this.test.emit(this.buildFormData(baseData));
if (this.urlControl.valid && this.keyControl.valid) {
const appriseData: AppriseFormData = {
...baseData,
url: this.urlControl.value || '',
key: this.keyControl.value || '',
tags: this.tagsControl.value || ''
};
this.test.emit(appriseData);
} else {
this.markFieldsTouched();
// Mark provider-specific fields as touched to show validation errors
this.urlControl.markAsTouched();
this.keyControl.markAsTouched();
}
}
// URL validation delegated to shared UrlValidators.httpUrl
}

View File

@@ -4,7 +4,7 @@
[closable]="true"
[draggable]="false"
[resizable]="false"
styleClass="notification-provider-modal"
styleClass="instance-modal"
[header]="modalTitle"
(onHide)="onCancel()"
>
@@ -111,7 +111,7 @@
<!-- Modal Footer -->
<ng-template pTemplate="footer">
<div class="pt-3">
<div>
<button pButton type="button" label="Cancel" class="p-button-text" (click)="onCancel()"></button>
<button
pButton

View File

@@ -1,6 +1,2 @@
/* Base Notification Provider Modal Styles */
@use '../../../styles/settings-shared.scss';
::ng-deep .notification-provider-modal.p-dialog {
max-width: 600px !important;
min-width: 320px !important;
}

View File

@@ -44,13 +44,26 @@
></i>
Topics *
</label>
<!-- Mobile-friendly autocomplete (chips UI) -->
<app-mobile-autocomplete
[formControl]="topicsControl"
placeholder="Enter topic names"
></app-mobile-autocomplete>
<!-- Desktop autocomplete (allows multiple entries) -->
<p-autocomplete
id="topics"
[formControl]="topicsControl"
multiple
fluid
[typeahead]="false"
placeholder="Add a topic and press Enter"
class="desktop-only w-full"
></p-autocomplete>
<small *ngIf="hasFieldError(topicsControl, 'required')" class="form-error-text">At least one topic is required</small>
<small *ngIf="hasFieldError(topicsControl, 'minlength')" class="form-error-text">At least one topic is required</small>
<small class="form-helper-text">Enter the ntfy topics you want to publish to. Press Enter or click + to add each topic.</small>
<small class="form-helper-text">Enter the ntfy topics you want to publish to. Press Enter or comma to add each topic.</small>
</div>
<!-- Authentication Type -->
@@ -177,11 +190,24 @@
></i>
Tags (Optional)
</label>
<!-- Mobile-friendly autocomplete (chips UI) -->
<app-mobile-autocomplete
[formControl]="tagsControl"
placeholder="Enter tag names"
></app-mobile-autocomplete>
<small class="form-helper-text">Optional tags to add to notifications (e.g., warning, alert). Press Enter or click + to add each tag.</small>
<!-- Desktop autocomplete (allows multiple entries) -->
<p-autocomplete
id="tags"
[formControl]="tagsControl"
multiple
fluid
[typeahead]="false"
placeholder="Add a tag and press Enter"
class="desktop-only w-full"
></p-autocomplete>
<small class="form-helper-text">Optional tags to add to notifications (e.g., warning, alert). Press Enter or comma to add each tag.</small>
</div>
</div>
</app-notification-provider-base>

View File

@@ -1,7 +1,8 @@
import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges, inject } from '@angular/core';
import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, OnChanges, SimpleChanges, inject } from '@angular/core';
import { FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { InputTextModule } from 'primeng/inputtext';
import { AutoCompleteModule } from 'primeng/autocomplete';
import { SelectModule } from 'primeng/select';
import { MobileAutocompleteComponent } from '../../../../shared/components/mobile-autocomplete/mobile-autocomplete.component';
import { NtfyFormData, BaseProviderFormData } from '../../models/provider-modal.model';
@@ -19,6 +20,7 @@ import { NtfyPriority } from '../../../../shared/models/ntfy-priority.enum';
CommonModule,
ReactiveFormsModule,
InputTextModule,
AutoCompleteModule,
SelectModule,
MobileAutocompleteComponent,
NotificationProviderBaseComponent

View File

@@ -1,4 +1,4 @@
import { AppriseMode, NotificationProviderType } from '../../../shared/models/enums';
import { NotificationProviderType } from '../../../shared/models/enums';
import { NtfyAuthenticationType } from '../../../shared/models/ntfy-authentication-type.enum';
import { NtfyPriority } from '../../../shared/models/ntfy-priority.enum';
import { PushoverPriority } from '../../../shared/models/pushover-priority.enum';
@@ -34,13 +34,9 @@ export interface NotifiarrFormData extends BaseProviderFormData {
}
export interface AppriseFormData extends BaseProviderFormData {
mode: AppriseMode;
// API mode fields
url: string;
key: string;
tags: string;
// CLI mode fields
serviceUrls: string;
}
export interface NtfyFormData extends BaseProviderFormData {

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");
}
});
}
@@ -281,11 +276,9 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
case NotificationProviderType.Apprise:
const appriseConfig = provider.configuration as any;
testRequest = {
mode: appriseConfig.mode,
url: appriseConfig.url || "",
key: appriseConfig.key || "",
url: appriseConfig.url,
key: appriseConfig.key,
tags: appriseConfig.tags || "",
serviceUrls: appriseConfig.serviceUrls || "",
};
break;
case NotificationProviderType.Ntfy:
@@ -417,11 +410,9 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
*/
onAppriseTest(data: AppriseFormData): void {
const testRequest = {
mode: data.mode,
url: data.url,
key: data.key,
tags: data.tags,
serviceUrls: data.serviceUrls,
};
this.notificationProviderStore.testProvider({
@@ -579,11 +570,9 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
onQueueItemDeleted: data.onQueueItemDeleted,
onDownloadCleaned: data.onDownloadCleaned,
onCategoryChanged: data.onCategoryChanged,
mode: data.mode,
url: data.url,
key: data.key,
tags: data.tags,
serviceUrls: data.serviceUrls,
};
this.notificationProviderStore.createProvider({
@@ -608,11 +597,9 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
onQueueItemDeleted: data.onQueueItemDeleted,
onDownloadCleaned: data.onDownloadCleaned,
onCategoryChanged: data.onCategoryChanged,
mode: data.mode,
url: data.url,
key: data.key,
tags: data.tags,
serviceUrls: data.serviceUrls,
};
this.notificationProviderStore.updateProvider({

Some files were not shown because too many files have changed in this diff Show More