mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-24 06:28:55 -05:00
Compare commits
4 Commits
add_appris
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
375094862c | ||
|
|
58a72cef0f | ||
|
|
4ceff127a7 | ||
|
|
c07b811cf8 |
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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}" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum AppriseMode
|
||||
{
|
||||
Api,
|
||||
Cli
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Files;
|
||||
|
||||
public interface IUnixHardLinkFileService : ISpecificFileService
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Files;
|
||||
|
||||
public interface IWindowsHardLinkFileService : ISpecificFileService
|
||||
{
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -128,8 +128,6 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
|
||||
await ChangeUnlinkedCategoriesAsync(isUnlinkedEnabled, downloadServiceToDownloadsMap, config);
|
||||
await CleanDownloadsAsync(downloadServiceToDownloadsMap, config);
|
||||
|
||||
|
||||
|
||||
foreach (var downloadService in downloadServices)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
|
||||
public interface IAppriseCliDetector
|
||||
{
|
||||
Task<string?> GetAppriseVersionAsync();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user