Compare commits

...

3 Commits

Author SHA1 Message Date
Flaminel
3a79e7778c added db migration 2026-01-14 10:52:13 +02:00
Flaminel
27141e7b8c added docs 2026-01-14 10:52:05 +02:00
Flaminel
31d36c71bb added Gotify notification provider 2026-01-14 10:51:56 +02:00
36 changed files with 2897 additions and 13 deletions

View File

@@ -5,6 +5,7 @@ using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
namespace Cleanuparr.Api.DependencyInjection;
@@ -20,6 +21,7 @@ public static class NotificationsDI
.AddScoped<IPushoverProxy, PushoverProxy>()
.AddScoped<ITelegramProxy, TelegramProxy>()
.AddScoped<IDiscordProxy, DiscordProxy>()
.AddScoped<IGotifyProxy, GotifyProxy>()
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
.AddScoped<NotificationProviderFactory>()

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record CreateGotifyProviderRequest : CreateNotificationProviderRequestBase
{
public string ServerUrl { get; init; } = string.Empty;
public string ApplicationToken { get; init; } = string.Empty;
public int Priority { get; init; } = 5;
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestGotifyProviderRequest
{
public string ServerUrl { get; init; } = string.Empty;
public string ApplicationToken { get; init; } = string.Empty;
public int Priority { get; init; } = 5;
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record UpdateGotifyProviderRequest : UpdateNotificationProviderRequestBase
{
public string ServerUrl { get; init; } = string.Empty;
public string ApplicationToken { get; init; } = string.Empty;
public int Priority { get; init; } = 5;
}

View File

@@ -8,6 +8,7 @@ using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.AspNetCore.Mvc;
@@ -52,6 +53,7 @@ public sealed class NotificationProvidersController : ControllerBase
.Include(p => p.PushoverConfiguration)
.Include(p => p.TelegramConfiguration)
.Include(p => p.DiscordConfiguration)
.Include(p => p.GotifyConfiguration)
.AsNoTracking()
.ToListAsync();
@@ -79,6 +81,7 @@ public sealed class NotificationProvidersController : ControllerBase
NotificationProviderType.Pushover => p.PushoverConfiguration ?? new object(),
NotificationProviderType.Telegram => p.TelegramConfiguration ?? new object(),
NotificationProviderType.Discord => p.DiscordConfiguration ?? new object(),
NotificationProviderType.Gotify => p.GotifyConfiguration ?? new object(),
_ => new object()
}
})
@@ -698,6 +701,7 @@ public sealed class NotificationProvidersController : ControllerBase
.Include(p => p.PushoverConfiguration)
.Include(p => p.TelegramConfiguration)
.Include(p => p.DiscordConfiguration)
.Include(p => p.GotifyConfiguration)
.FirstOrDefaultAsync(p => p.Id == id);
if (existingProvider == null)
@@ -931,6 +935,7 @@ public sealed class NotificationProvidersController : ControllerBase
NotificationProviderType.Pushover => provider.PushoverConfiguration ?? new object(),
NotificationProviderType.Telegram => provider.TelegramConfiguration ?? new object(),
NotificationProviderType.Discord => provider.DiscordConfiguration ?? new object(),
NotificationProviderType.Gotify => provider.GotifyConfiguration ?? new object(),
_ => new object()
}
};
@@ -1321,4 +1326,192 @@ public sealed class NotificationProvidersController : ControllerBase
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
[HttpPost("gotify")]
public async Task<IActionResult> CreateGotifyProvider([FromBody] CreateGotifyProviderRequest newProvider)
{
await DataContext.Lock.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var gotifyConfig = new GotifyConfig
{
ServerUrl = newProvider.ServerUrl,
ApplicationToken = newProvider.ApplicationToken,
Priority = newProvider.Priority
};
gotifyConfig.Validate();
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Gotify,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
GotifyConfiguration = gotifyConfig
};
_dataContext.NotificationConfigs.Add(provider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Gotify provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("gotify/{id:guid}")]
public async Task<IActionResult> UpdateGotifyProvider(Guid id, [FromBody] UpdateGotifyProviderRequest updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.GotifyConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Gotify);
if (existingProvider == null)
{
return NotFound($"Gotify provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
.Where(x => x.Id != id)
.Where(x => x.Name == updatedProvider.Name)
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var gotifyConfig = new GotifyConfig
{
ServerUrl = updatedProvider.ServerUrl,
ApplicationToken = updatedProvider.ApplicationToken,
Priority = updatedProvider.Priority
};
if (existingProvider.GotifyConfiguration != null)
{
gotifyConfig = gotifyConfig with { Id = existingProvider.GotifyConfiguration.Id };
}
gotifyConfig.Validate();
var newProvider = existingProvider with
{
Name = updatedProvider.Name,
IsEnabled = updatedProvider.IsEnabled,
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
OnStalledStrike = updatedProvider.OnStalledStrike,
OnSlowStrike = updatedProvider.OnSlowStrike,
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
GotifyConfiguration = gotifyConfig,
UpdatedAt = DateTime.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
_dataContext.NotificationConfigs.Add(newProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Gotify provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("gotify/test")]
public async Task<IActionResult> TestGotifyProvider([FromBody] TestGotifyProviderRequest testRequest)
{
try
{
var gotifyConfig = new GotifyConfig
{
ServerUrl = testRequest.ServerUrl,
ApplicationToken = testRequest.ApplicationToken,
Priority = testRequest.Priority
};
gotifyConfig.Validate();
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Gotify,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = gotifyConfig
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully" });
}
catch (GotifyException ex)
{
_logger.LogWarning(ex, "Failed to test Gotify provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Gotify provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
}

View File

@@ -8,4 +8,5 @@ public enum NotificationProviderType
Pushover,
Telegram,
Discord,
Gotify,
}

View File

@@ -0,0 +1,329 @@
using System.Net;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Protected;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Gotify;
public class GotifyProxyTests
{
private readonly Mock<ILogger<GotifyProxy>> _loggerMock;
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
public GotifyProxyTests()
{
_loggerMock = new Mock<ILogger<GotifyProxy>>();
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_httpClientFactoryMock
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
.Returns(httpClient);
}
private GotifyProxy CreateProxy()
{
return new GotifyProxy(_loggerMock.Object, _httpClientFactoryMock.Object);
}
private static GotifyPayload CreatePayload()
{
return new GotifyPayload
{
Title = "Test Title",
Message = "Test Message",
Priority = 5
};
}
private static GotifyConfig CreateConfig()
{
return new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "test-app-token",
Priority = 5
};
}
#region Constructor Tests
[Fact]
public void Constructor_WithValidDependencies_CreatesInstance()
{
// Act
var proxy = CreateProxy();
// Assert
Assert.NotNull(proxy);
}
[Fact]
public void Constructor_CreatesHttpClientWithCorrectName()
{
// Act
_ = CreateProxy();
// Assert
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
}
#endregion
#region SendNotification Success Tests
[Fact]
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
{
// Arrange
var proxy = CreateProxy();
SetupSuccessResponse();
// Act & Assert - Should not throw
await proxy.SendNotification(CreatePayload(), CreateConfig());
}
[Fact]
public async Task SendNotification_SendsPostRequest()
{
// Arrange
var proxy = CreateProxy();
HttpMethod? capturedMethod = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
Assert.Equal(HttpMethod.Post, capturedMethod);
}
[Fact]
public async Task SendNotification_BuildsCorrectUrl()
{
// Arrange
var proxy = CreateProxy();
Uri? capturedUri = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "my-token",
Priority = 5
};
// Act
await proxy.SendNotification(CreatePayload(), config);
// Assert
Assert.NotNull(capturedUri);
Assert.Equal("https://gotify.example.com/message?token=my-token", capturedUri.ToString());
}
[Fact]
public async Task SendNotification_TrimsTrailingSlashFromServerUrl()
{
// Arrange
var proxy = CreateProxy();
Uri? capturedUri = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com/",
ApplicationToken = "my-token",
Priority = 5
};
// Act
await proxy.SendNotification(CreatePayload(), config);
// Assert
Assert.NotNull(capturedUri);
Assert.Equal("https://gotify.example.com/message?token=my-token", capturedUri.ToString());
}
[Fact]
public async Task SendNotification_SetsJsonContentType()
{
// Arrange
var proxy = CreateProxy();
string? capturedContentType = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
Assert.Equal("application/json", capturedContentType);
}
[Fact]
public async Task SendNotification_LogsTraceWithContent()
{
// Arrange
var proxy = CreateProxy();
SetupSuccessResponse();
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Trace,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("sending notification")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region SendNotification Error Tests
[Theory]
[InlineData(HttpStatusCode.Unauthorized)]
[InlineData(HttpStatusCode.Forbidden)]
public async Task SendNotification_WhenUnauthorized_ThrowsGotifyExceptionWithInvalidToken(HttpStatusCode statusCode)
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(statusCode);
// Act & Assert
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("invalid or unauthorized", ex.Message);
}
[Fact]
public async Task SendNotification_When404_ThrowsGotifyExceptionWithNotFound()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.NotFound);
// Act & Assert
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("not found", ex.Message);
}
[Theory]
[InlineData(HttpStatusCode.BadGateway)]
[InlineData(HttpStatusCode.ServiceUnavailable)]
[InlineData(HttpStatusCode.GatewayTimeout)]
public async Task SendNotification_WhenServiceUnavailable_ThrowsGotifyException(HttpStatusCode statusCode)
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(statusCode);
// Act & Assert
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("service unavailable", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SendNotification_WhenOtherError_ThrowsGotifyException()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.InternalServerError);
// Act & Assert
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SendNotification_WhenNetworkError_ThrowsGotifyException()
{
// Arrange
var proxy = CreateProxy();
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Network error"));
// Act & Assert
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region Helper Methods
private void SetupSuccessResponse()
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
}
private void SetupErrorResponse(HttpStatusCode statusCode)
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Error", null, statusCode));
}
#endregion
}

View File

@@ -358,13 +358,9 @@ public class NotificationConfigurationServiceTests : IDisposable
#region Provider Type Mapping Tests
[Theory]
[InlineData(NotificationProviderType.Notifiarr)]
[InlineData(NotificationProviderType.Apprise)]
[InlineData(NotificationProviderType.Ntfy)]
[InlineData(NotificationProviderType.Pushover)]
[InlineData(NotificationProviderType.Telegram)]
[InlineData(NotificationProviderType.Discord)]
[MemberData(nameof(NotificationProviderTypes))]
public async Task GetActiveProvidersAsync_MapsProviderTypeCorrectly(NotificationProviderType providerType)
{
// Arrange
@@ -384,12 +380,7 @@ public class NotificationConfigurationServiceTests : IDisposable
}
[Theory]
[InlineData(NotificationProviderType.Notifiarr)]
[InlineData(NotificationProviderType.Apprise)]
[InlineData(NotificationProviderType.Ntfy)]
[InlineData(NotificationProviderType.Pushover)]
[InlineData(NotificationProviderType.Telegram)]
[InlineData(NotificationProviderType.Discord)]
[MemberData(nameof(NotificationProviderTypes))]
public async Task GetProvidersForEventAsync_ReturnsProviderForAllTypes(NotificationProviderType providerType)
{
// Arrange
@@ -423,6 +414,7 @@ public class NotificationConfigurationServiceTests : IDisposable
NotificationProviderType.Pushover => CreatePushoverConfig(name, isEnabled),
NotificationProviderType.Telegram => CreateTelegramConfig(name, isEnabled),
NotificationProviderType.Discord => CreateDiscordConfig(name, isEnabled),
NotificationProviderType.Gotify => CreateGotifyConfig(name, isEnabled),
_ => throw new ArgumentOutOfRangeException(nameof(providerType))
};
}
@@ -576,5 +568,36 @@ public class NotificationConfigurationServiceTests : IDisposable
};
}
private static NotificationConfig CreateGotifyConfig(string name, bool isEnabled)
{
return new NotificationConfig
{
Id = Guid.NewGuid(),
Name = name,
Type = NotificationProviderType.Gotify,
IsEnabled = isEnabled,
OnStalledStrike = true,
OnFailedImportStrike = true,
OnSlowStrike = true,
OnQueueItemDeleted = true,
OnDownloadCleaned = true,
OnCategoryChanged = true,
GotifyConfiguration = new GotifyConfig
{
Id = Guid.NewGuid(),
ServerUrl = "http://localhost:8000",
ApplicationToken = "test_application_token",
}
};
}
#endregion
public static IEnumerable<object[]> NotificationProviderTypes =>
[
..Enum.GetValues<NotificationProviderType>()
.Cast<Object>()
.Select(x => new[] { x })
.ToList()
];
}

View File

@@ -2,6 +2,7 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
@@ -23,6 +24,7 @@ public class NotificationProviderFactoryTests
private readonly Mock<IPushoverProxy> _pushoverProxyMock;
private readonly Mock<ITelegramProxy> _telegramProxyMock;
private readonly Mock<IDiscordProxy> _discordProxyMock;
private readonly Mock<IGotifyProxy> _gotifyProxyMock;
private readonly IServiceProvider _serviceProvider;
private readonly NotificationProviderFactory _factory;
@@ -35,6 +37,7 @@ public class NotificationProviderFactoryTests
_pushoverProxyMock = new Mock<IPushoverProxy>();
_telegramProxyMock = new Mock<ITelegramProxy>();
_discordProxyMock = new Mock<IDiscordProxy>();
_gotifyProxyMock = new Mock<IGotifyProxy>();
var services = new ServiceCollection();
services.AddSingleton(_appriseProxyMock.Object);
@@ -44,6 +47,7 @@ public class NotificationProviderFactoryTests
services.AddSingleton(_pushoverProxyMock.Object);
services.AddSingleton(_telegramProxyMock.Object);
services.AddSingleton(_discordProxyMock.Object);
services.AddSingleton(_gotifyProxyMock.Object);
_serviceProvider = services.BuildServiceProvider();
_factory = new NotificationProviderFactory(_serviceProvider);
@@ -227,6 +231,32 @@ public class NotificationProviderFactoryTests
Assert.Equal(NotificationProviderType.Discord, provider.Type);
}
[Fact]
public void CreateProvider_GotifyType_CreatesGotifyProvider()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestGotify",
Type = NotificationProviderType.Gotify,
IsEnabled = true,
Configuration = new GotifyConfig
{
Id = Guid.NewGuid(),
ServerUrl = "test-server-url",
ApplicationToken = "test-application-token",
}
};
var provider = _factory.CreateProvider(config);
Assert.NotNull(provider);
Assert.IsType<GotifyProvider>(provider);
Assert.Equal("TestGotify", provider.Name);
Assert.Equal(NotificationProviderType.Gotify, provider.Type);
}
[Fact]
public void CreateProvider_UnsupportedType_ThrowsNotSupportedException()
{

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
public class GotifyException : Exception
{
public GotifyException(string message) : base(message)
{
}
public GotifyException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,25 @@
using Newtonsoft.Json;
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
public class GotifyPayload
{
public string Title { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public int Priority { get; set; } = 5;
public GotifyExtras? Extras { get; set; }
}
public class GotifyExtras
{
[JsonProperty("client::display")]
public GotifyClientDisplay? ClientDisplay { get; set; }
}
public class GotifyClientDisplay
{
public string? ContentType { get; set; }
}

View File

@@ -0,0 +1,62 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Notification;
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
public sealed class GotifyProvider : NotificationProviderBase<GotifyConfig>
{
private readonly IGotifyProxy _proxy;
public GotifyProvider(
string name,
NotificationProviderType type,
GotifyConfig config,
IGotifyProxy proxy)
: base(name, type, config)
{
_proxy = proxy;
}
public override async Task SendNotificationAsync(NotificationContext context)
{
var payload = BuildPayload(context);
await _proxy.SendNotification(payload, Config);
}
private GotifyPayload BuildPayload(NotificationContext context)
{
var message = BuildMessage(context);
return new GotifyPayload
{
Title = context.Title,
Message = message,
Priority = Config.Priority,
Extras = new GotifyExtras
{
ClientDisplay = new GotifyClientDisplay
{
ContentType = "text/markdown"
}
}
};
}
private string BuildMessage(NotificationContext context)
{
var lines = new List<string>();
if (!string.IsNullOrWhiteSpace(context.Description))
{
lines.Add(context.Description);
}
foreach ((string key, string value) in context.Data)
{
lines.Add($"**{key}:** {value}");
}
return string.Join("\n\n", lines);
}
}

View File

@@ -0,0 +1,65 @@
using System.Text;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
public sealed class GotifyProxy : IGotifyProxy
{
private readonly ILogger<GotifyProxy> _logger;
private readonly HttpClient _httpClient;
public GotifyProxy(ILogger<GotifyProxy> logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
}
public async Task SendNotification(GotifyPayload payload, GotifyConfig config)
{
try
{
string baseUrl = config.ServerUrl.TrimEnd('/');
string url = $"{baseUrl}/message?token={config.ApplicationToken}";
string content = JsonConvert.SerializeObject(payload, new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Ignore
});
_logger.LogTrace("sending notification to Gotify: {content}", content);
using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, url);
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
using HttpResponseMessage response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
}
catch (HttpRequestException exception)
{
if (exception.StatusCode is null)
{
throw new GotifyException("unable to send notification", exception);
}
switch ((int)exception.StatusCode)
{
case 401:
case 403:
throw new GotifyException("unable to send notification | application token is invalid or unauthorized");
case 404:
throw new GotifyException("unable to send notification | Gotify server not found");
case 502:
case 503:
case 504:
throw new GotifyException("unable to send notification | Gotify service unavailable", exception);
default:
throw new GotifyException("unable to send notification", exception);
}
}
}
}

View File

@@ -0,0 +1,8 @@
using Cleanuparr.Persistence.Models.Configuration.Notification;
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
public interface IGotifyProxy
{
Task SendNotification(GotifyPayload payload, GotifyConfig config);
}

View File

@@ -89,6 +89,7 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
.Include(p => p.PushoverConfiguration)
.Include(p => p.TelegramConfiguration)
.Include(p => p.DiscordConfiguration)
.Include(p => p.GotifyConfiguration)
.AsNoTracking()
.ToListAsync();
@@ -141,6 +142,7 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
NotificationProviderType.Pushover => config.PushoverConfiguration,
NotificationProviderType.Telegram => config.TelegramConfiguration,
NotificationProviderType.Discord => config.DiscordConfiguration,
NotificationProviderType.Gotify => config.GotifyConfiguration,
_ => throw new ArgumentOutOfRangeException(nameof(config), $"Config type for provider type {config.Type.ToString()} is not registered")
};

View File

@@ -7,6 +7,7 @@ using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.Extensions.DependencyInjection;
@@ -31,6 +32,7 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory
NotificationProviderType.Pushover => CreatePushoverProvider(config),
NotificationProviderType.Telegram => CreateTelegramProvider(config),
NotificationProviderType.Discord => CreateDiscordProvider(config),
NotificationProviderType.Gotify => CreateGotifyProvider(config),
_ => throw new NotSupportedException($"Provider type {config.Type} is not supported")
};
}
@@ -83,4 +85,12 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory
return new DiscordProvider(config.Name, config.Type, discordConfig, proxy);
}
private INotificationProvider CreateGotifyProvider(NotificationProviderDto config)
{
var gotifyConfig = (GotifyConfig)config.Configuration;
var proxy = _serviceProvider.GetRequiredService<IGotifyProxy>();
return new GotifyProvider(config.Name, config.Type, gotifyConfig, proxy);
}
}

View File

@@ -0,0 +1,189 @@
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Shouldly;
using Xunit;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Tests.Models.Configuration.Notification;
public sealed class GotifyConfigTests
{
#region IsValid Tests
[Fact]
public void IsValid_WithValidConfig_ReturnsTrue()
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "test-app-token"
};
config.IsValid().ShouldBeTrue();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void IsValid_WithEmptyOrNullServerUrl_ReturnsFalse(string? serverUrl)
{
var config = new GotifyConfig
{
ServerUrl = serverUrl ?? string.Empty,
ApplicationToken = "test-token"
};
config.IsValid().ShouldBeFalse();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void IsValid_WithEmptyOrNullApplicationToken_ReturnsFalse(string? token)
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = token ?? string.Empty
};
config.IsValid().ShouldBeFalse();
}
#endregion
#region Validate Tests
[Fact]
public void Validate_WithValidConfig_DoesNotThrow()
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "test-app-token",
Priority = 5
};
Should.NotThrow(() => config.Validate());
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_WithEmptyOrNullServerUrl_ThrowsValidationException(string? serverUrl)
{
var config = new GotifyConfig
{
ServerUrl = serverUrl ?? string.Empty,
ApplicationToken = "test-token"
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("required");
}
[Theory]
[InlineData("not-a-url")]
[InlineData("ftp://gotify.example.com")]
[InlineData("invalid://scheme")]
public void Validate_WithInvalidServerUrl_ThrowsValidationException(string serverUrl)
{
var config = new GotifyConfig
{
ServerUrl = serverUrl,
ApplicationToken = "test-token"
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("valid HTTP or HTTPS URL");
}
[Theory]
[InlineData("https://gotify.example.com")]
[InlineData("http://localhost:8080")]
[InlineData("https://gotify.local:8443/")]
public void Validate_WithValidServerUrls_DoesNotThrow(string serverUrl)
{
var config = new GotifyConfig
{
ServerUrl = serverUrl,
ApplicationToken = "test-token"
};
Should.NotThrow(() => config.Validate());
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_WithEmptyOrNullApplicationToken_ThrowsValidationException(string? token)
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = token ?? string.Empty
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("required");
}
[Theory]
[InlineData(-1)]
[InlineData(11)]
[InlineData(100)]
public void Validate_WithInvalidPriority_ThrowsValidationException(int priority)
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "test-token",
Priority = priority
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("Priority");
}
[Theory]
[InlineData(0)]
[InlineData(5)]
[InlineData(10)]
public void Validate_WithValidPriority_DoesNotThrow(int priority)
{
var config = new GotifyConfig
{
ServerUrl = "https://gotify.example.com",
ApplicationToken = "test-token",
Priority = priority
};
Should.NotThrow(() => config.Validate());
}
#endregion
#region Default Values Tests
[Fact]
public void NewConfig_HasDefaultPriorityOf5()
{
var config = new GotifyConfig();
config.Priority.ShouldBe(5);
}
[Fact]
public void NewConfig_HasEmptyStringsForRequiredFields()
{
var config = new GotifyConfig();
config.ServerUrl.ShouldBe(string.Empty);
config.ApplicationToken.ShouldBe(string.Empty);
}
#endregion
}

View File

@@ -58,6 +58,8 @@ public class DataContext : DbContext
public DbSet<DiscordConfig> DiscordConfigs { get; set; }
public DbSet<GotifyConfig> GotifyConfigs { get; set; }
public DbSet<BlacklistSyncHistory> BlacklistSyncHistory { get; set; }
public DbSet<BlacklistSyncConfig> BlacklistSyncConfigs { get; set; }
@@ -163,6 +165,11 @@ public class DataContext : DbContext
.HasForeignKey<DiscordConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.GotifyConfiguration)
.WithOne(c => c.NotificationConfig)
.HasForeignKey<GotifyConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(p => p.Name).IsUnique();
});

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddGotify : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "gotify_configs",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
notification_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
server_url = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
application_token = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
priority = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_gotify_configs", x => x.id);
table.ForeignKey(
name: "fk_gotify_configs_notification_configs_notification_config_id",
column: x => x.notification_config_id,
principalTable: "notification_configs",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_gotify_configs_notification_config_id",
table: "gotify_configs",
column: "notification_config_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "gotify_configs");
}
}
}

View File

@@ -561,6 +561,43 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.ToTable("discord_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApplicationToken")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("application_token");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<int>("Priority")
.HasColumnType("INTEGER")
.HasColumnName("priority");
b.Property<string>("ServerUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("server_url");
b.HasKey("Id")
.HasName("pk_gotify_configs");
b.HasIndex("NotificationConfigId")
.IsUnique()
.HasDatabaseName("ix_gotify_configs_notification_config_id");
b.ToTable("gotify_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.Property<Guid>("Id")
@@ -1094,6 +1131,18 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
.WithOne("GotifyConfiguration")
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", "NotificationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_gotify_configs_notification_configs_notification_config_id");
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
@@ -1194,6 +1243,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.Navigation("DiscordConfiguration");
b.Navigation("GotifyConfiguration");
b.Navigation("NotifiarrConfiguration");
b.Navigation("NtfyConfiguration");

View File

@@ -0,0 +1,57 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
public sealed record GotifyConfig : IConfig
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; init; } = Guid.NewGuid();
[Required]
public Guid NotificationConfigId { get; init; }
[ForeignKey(nameof(NotificationConfigId))]
public NotificationConfig NotificationConfig { get; init; } = null!;
[Required]
[MaxLength(500)]
public string ServerUrl { get; init; } = string.Empty;
[Required]
[MaxLength(200)]
public string ApplicationToken { get; init; } = string.Empty;
public int Priority { get; init; } = 5;
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(ServerUrl) && !string.IsNullOrWhiteSpace(ApplicationToken);
}
public void Validate()
{
if (string.IsNullOrWhiteSpace(ServerUrl))
{
throw new ValidationException("Gotify server URL is required");
}
if (!Uri.TryCreate(ServerUrl, UriKind.Absolute, out var uri) ||
(uri.Scheme != "http" && uri.Scheme != "https"))
{
throw new ValidationException("Gotify server URL must be a valid HTTP or HTTPS URL");
}
if (string.IsNullOrWhiteSpace(ApplicationToken))
{
throw new ValidationException("Gotify application token is required");
}
if (Priority < 0 || Priority > 10)
{
throw new ValidationException("Priority must be between 0 and 10");
}
}
}

View File

@@ -47,6 +47,8 @@ public sealed record NotificationConfig
public DiscordConfig? DiscordConfiguration { get; init; }
public GotifyConfig? GotifyConfiguration { get; init; }
[NotMapped]
public bool IsConfigured => Type switch
{
@@ -56,6 +58,7 @@ public sealed record NotificationConfig
NotificationProviderType.Pushover => PushoverConfiguration?.IsValid() == true,
NotificationProviderType.Telegram => TelegramConfiguration?.IsValid() == true,
NotificationProviderType.Discord => DiscordConfiguration?.IsValid() == true,
NotificationProviderType.Gotify => GotifyConfiguration?.IsValid() == true,
_ => throw new ArgumentOutOfRangeException(nameof(Type), $"Invalid notification provider type {Type}")
};

View File

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -157,6 +157,11 @@ export class DocumentationService {
'discord.username': 'username',
'discord.avatarUrl': 'avatar-url'
},
'notifications/gotify': {
'gotify.serverUrl': 'server-url',
'gotify.applicationToken': 'application-token',
'gotify.priority': 'priority'
},
};
constructor(private applicationPathService: ApplicationPathService) {}

View File

@@ -264,6 +264,40 @@ export interface TestDiscordProviderRequest {
avatarUrl: string;
}
export interface CreateGotifyProviderRequest {
name: string;
isEnabled: boolean;
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
serverUrl: string;
applicationToken: string;
priority: number;
}
export interface UpdateGotifyProviderRequest {
name: string;
isEnabled: boolean;
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
serverUrl: string;
applicationToken: string;
priority: number;
}
export interface TestGotifyProviderRequest {
serverUrl: string;
applicationToken: string;
priority: number;
}
@Injectable({
providedIn: 'root'
})
@@ -328,6 +362,13 @@ export class NotificationProviderService {
return this.http.post<NotificationProviderDto>(`${this.baseUrl}/discord`, provider);
}
/**
* Create a new Gotify provider
*/
createGotifyProvider(provider: CreateGotifyProviderRequest): Observable<NotificationProviderDto> {
return this.http.post<NotificationProviderDto>(`${this.baseUrl}/gotify`, provider);
}
/**
* Update an existing Notifiarr provider
*/
@@ -370,6 +411,13 @@ export class NotificationProviderService {
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/discord/${id}`, provider);
}
/**
* Update an existing Gotify provider
*/
updateGotifyProvider(id: string, provider: UpdateGotifyProviderRequest): Observable<NotificationProviderDto> {
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/gotify/${id}`, provider);
}
/**
* Delete a notification provider
*/
@@ -419,6 +467,13 @@ export class NotificationProviderService {
return this.http.post<TestNotificationResult>(`${this.baseUrl}/discord/test`, testRequest);
}
/**
* Test a Gotify provider (without ID - for testing configuration before saving)
*/
testGotifyProvider(testRequest: TestGotifyProviderRequest): Observable<TestNotificationResult> {
return this.http.post<TestNotificationResult>(`${this.baseUrl}/gotify/test`, testRequest);
}
/**
* Generic create method that delegates to provider-specific methods
*/
@@ -436,6 +491,8 @@ export class NotificationProviderService {
return this.createTelegramProvider(provider as CreateTelegramProviderRequest);
case NotificationProviderType.Discord:
return this.createDiscordProvider(provider as CreateDiscordProviderRequest);
case NotificationProviderType.Gotify:
return this.createGotifyProvider(provider as CreateGotifyProviderRequest);
default:
throw new Error(`Unsupported provider type: ${type}`);
}
@@ -458,6 +515,8 @@ export class NotificationProviderService {
return this.updateTelegramProvider(id, provider as UpdateTelegramProviderRequest);
case NotificationProviderType.Discord:
return this.updateDiscordProvider(id, provider as UpdateDiscordProviderRequest);
case NotificationProviderType.Gotify:
return this.updateGotifyProvider(id, provider as UpdateGotifyProviderRequest);
default:
throw new Error(`Unsupported provider type: ${type}`);
}
@@ -480,6 +539,8 @@ export class NotificationProviderService {
return this.testTelegramProvider(testRequest as TestTelegramProviderRequest);
case NotificationProviderType.Discord:
return this.testDiscordProvider(testRequest as TestDiscordProviderRequest);
case NotificationProviderType.Gotify:
return this.testGotifyProvider(testRequest as TestGotifyProviderRequest);
default:
throw new Error(`Unsupported provider type: ${type}`);
}

View File

@@ -0,0 +1,72 @@
<app-notification-provider-base
[visible]="visible"
modalTitle="Configure Gotify Provider"
[saving]="saving"
[testing]="testing"
[editingProvider]="editingProvider"
(save)="onSave($event)"
(cancel)="onCancel()"
(test)="onTest($event)">
<!-- Provider-specific configuration goes here -->
<div slot="provider-config">
<!-- Server URL Field -->
<div class="field">
<label for="server-url">
<i class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('gotify.serverUrl')"></i>
Server URL *
</label>
<input
id="server-url"
type="text"
pInputText
[formControl]="serverUrlControl"
placeholder="https://gotify.example.com"
class="w-full" />
<small *ngIf="hasFieldError(serverUrlControl, 'required')" class="form-error-text">Server URL is required</small>
<small *ngIf="hasFieldError(serverUrlControl, 'pattern')" class="form-error-text">Must be a valid HTTP or HTTPS URL</small>
<small class="form-helper-text">The URL of your Gotify server instance.</small>
</div>
<!-- Application Token Field -->
<div class="field">
<label for="application-token">
<i class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('gotify.applicationToken')"></i>
Application Token *
</label>
<input
id="application-token"
type="password"
pInputText
[formControl]="applicationTokenControl"
placeholder="Your Gotify application token"
class="w-full" />
<small *ngIf="hasFieldError(applicationTokenControl, 'required')" class="form-error-text">Application token is required</small>
<small class="form-helper-text">Create an application in your Gotify server and use its token here.</small>
</div>
<!-- Priority Field -->
<div class="field">
<label for="priority">
<i class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('gotify.priority')"></i>
Priority
</label>
<p-inputNumber
id="priority"
[formControl]="priorityControl"
[min]="0"
[max]="10"
[showButtons]="true"
styleClass="w-full">
</p-inputNumber>
<small *ngIf="hasFieldError(priorityControl, 'min') || hasFieldError(priorityControl, 'max')" class="form-error-text">Priority must be between 0 and 10</small>
<small class="form-helper-text">Message priority (0-10). Higher values may trigger different notification behaviors on clients.</small>
</div>
</div>
</app-notification-provider-base>

View File

@@ -0,0 +1 @@
@use '../../../styles/settings-shared.scss';

View File

@@ -0,0 +1,119 @@
import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges, inject } from '@angular/core';
import { FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { InputTextModule } from 'primeng/inputtext';
import { InputNumberModule } from 'primeng/inputnumber';
import { GotifyFormData, BaseProviderFormData } from '../../models/provider-modal.model';
import { DocumentationService } from '../../../../core/services/documentation.service';
import { NotificationProviderDto } from '../../../../shared/models/notification-provider.model';
import { NotificationProviderBaseComponent } from '../base/notification-provider-base.component';
@Component({
selector: 'app-gotify-provider',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
InputTextModule,
InputNumberModule,
NotificationProviderBaseComponent
],
templateUrl: './gotify-provider.component.html',
styleUrls: ['./gotify-provider.component.scss']
})
export class GotifyProviderComponent implements OnInit, OnChanges {
@Input() visible = false;
@Input() editingProvider: NotificationProviderDto | null = null;
@Input() saving = false;
@Input() testing = false;
@Output() save = new EventEmitter<GotifyFormData>();
@Output() cancel = new EventEmitter<void>();
@Output() test = new EventEmitter<GotifyFormData>();
// Provider-specific form controls
serverUrlControl = new FormControl('', [Validators.required, Validators.pattern(/^https?:\/\/.+/)]);
applicationTokenControl = new FormControl('', [Validators.required]);
priorityControl = new FormControl(5, [Validators.required, Validators.min(0), Validators.max(10)]);
private documentationService = inject(DocumentationService);
/** Exposed for template to open documentation for gotify fields */
openFieldDocs(fieldName: string): void {
this.documentationService.openFieldDocumentation('notifications/gotify', fieldName);
}
ngOnInit(): void {
// Initialize component but don't populate yet - wait for ngOnChanges
}
ngOnChanges(changes: SimpleChanges): void {
// Populate provider-specific fields when editingProvider input changes
if (changes['editingProvider']) {
if (this.editingProvider) {
this.populateProviderFields();
} else {
// Reset fields when editingProvider is cleared
this.resetProviderFields();
}
}
}
private populateProviderFields(): void {
if (this.editingProvider) {
const config = this.editingProvider.configuration as any;
this.serverUrlControl.setValue(config?.serverUrl || '');
this.applicationTokenControl.setValue(config?.applicationToken || '');
this.priorityControl.setValue(config?.priority ?? 5);
}
}
private resetProviderFields(): void {
this.serverUrlControl.setValue('');
this.applicationTokenControl.setValue('');
this.priorityControl.setValue(5);
}
protected hasFieldError(control: FormControl, errorType: string): boolean {
return !!(control && control.errors?.[errorType] && (control.dirty || control.touched));
}
onSave(baseData: BaseProviderFormData): void {
if (this.serverUrlControl.valid && this.applicationTokenControl.valid && this.priorityControl.valid) {
const gotifyData: GotifyFormData = {
...baseData,
serverUrl: this.serverUrlControl.value || '',
applicationToken: this.applicationTokenControl.value || '',
priority: this.priorityControl.value ?? 5
};
this.save.emit(gotifyData);
} else {
// Mark provider-specific fields as touched to show validation errors
this.serverUrlControl.markAsTouched();
this.applicationTokenControl.markAsTouched();
this.priorityControl.markAsTouched();
}
}
onCancel(): void {
this.cancel.emit();
}
onTest(baseData: BaseProviderFormData): void {
if (this.serverUrlControl.valid && this.applicationTokenControl.valid && this.priorityControl.valid) {
const gotifyData: GotifyFormData = {
...baseData,
serverUrl: this.serverUrlControl.value || '',
applicationToken: this.applicationTokenControl.value || '',
priority: this.priorityControl.value ?? 5
};
this.test.emit(gotifyData);
} else {
// Mark provider-specific fields as touched to show validation errors
this.serverUrlControl.markAsTouched();
this.applicationTokenControl.markAsTouched();
this.priorityControl.markAsTouched();
}
}
}

View File

@@ -65,6 +65,13 @@ export class ProviderTypeSelectionComponent {
iconUrlHover: 'icons/ext/discord.svg',
description: 'https://discord.com'
},
{
type: NotificationProviderType.Gotify,
name: 'Gotify',
iconUrl: 'icons/ext/gotify-light.svg',
iconUrlHover: 'icons/ext/gotify.svg',
description: 'https://gotify.net'
},
].sort((a, b) => a.name.localeCompare(b.name));
selectProvider(type: NotificationProviderType) {

View File

@@ -78,6 +78,12 @@ export interface DiscordFormData extends BaseProviderFormData {
avatarUrl: string;
}
export interface GotifyFormData extends BaseProviderFormData {
serverUrl: string;
applicationToken: string;
priority: number;
}
// Events for modal communication
export interface ProviderModalEvents {
save: (data: any) => void;

View File

@@ -219,6 +219,17 @@
(test)="onDiscordTest($event)"
></app-discord-provider>
<!-- Gotify Provider Modal -->
<app-gotify-provider
[visible]="showGotifyModal"
[editingProvider]="editingProvider"
[saving]="saving()"
[testing]="testing()"
(save)="onGotifySave($event)"
(cancel)="onProviderCancel()"
(test)="onGotifyTest($event)"
></app-gotify-provider>
<!-- Confirmation Dialog -->
<p-confirmDialog></p-confirmDialog>

View File

@@ -8,7 +8,7 @@ import {
} from "../../shared/models/notification-provider.model";
import { NotificationProviderType } from "../../shared/models/enums";
import { DocumentationService } from "../../core/services/documentation.service";
import { NotifiarrFormData, AppriseFormData, NtfyFormData, PushoverFormData, TelegramFormData, DiscordFormData } from "./models/provider-modal.model";
import { NotifiarrFormData, AppriseFormData, NtfyFormData, PushoverFormData, TelegramFormData, DiscordFormData, GotifyFormData } from "./models/provider-modal.model";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
// New modal components
@@ -19,6 +19,7 @@ import { NtfyProviderComponent } from "./modals/ntfy-provider/ntfy-provider.comp
import { PushoverProviderComponent } from "./modals/pushover-provider/pushover-provider.component";
import { TelegramProviderComponent } from "./modals/telegram-provider/telegram-provider.component";
import { DiscordProviderComponent } from "./modals/discord-provider/discord-provider.component";
import { GotifyProviderComponent } from "./modals/gotify-provider/gotify-provider.component";
// PrimeNG Components
import { CardModule } from "primeng/card";
@@ -57,6 +58,7 @@ import { NotificationService } from "../../core/services/notification.service";
PushoverProviderComponent,
TelegramProviderComponent,
DiscordProviderComponent,
GotifyProviderComponent,
],
providers: [NotificationProviderConfigStore, ConfirmationService, MessageService],
templateUrl: "./notification-settings.component.html",
@@ -75,6 +77,7 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
showPushoverModal = false;
showTelegramModal = false;
showDiscordModal = false;
showGotifyModal = false;
modalMode: 'add' | 'edit' = 'add';
editingProvider: NotificationProviderDto | null = null;
@@ -192,6 +195,9 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
case NotificationProviderType.Discord:
this.showDiscordModal = true;
break;
case NotificationProviderType.Gotify:
this.showGotifyModal = true;
break;
default:
// For unsupported types, show the legacy modal with info message
this.showProviderModal = true;
@@ -251,6 +257,9 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
case NotificationProviderType.Discord:
this.showDiscordModal = true;
break;
case NotificationProviderType.Gotify:
this.showGotifyModal = true;
break;
default:
// For unsupported types, show the legacy modal with info message
this.showProviderModal = true;
@@ -344,6 +353,14 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
avatarUrl: discordConfig.avatarUrl || "",
};
break;
case NotificationProviderType.Gotify:
const gotifyConfig = provider.configuration as any;
testRequest = {
serverUrl: gotifyConfig.serverUrl,
applicationToken: gotifyConfig.applicationToken,
priority: gotifyConfig.priority ?? 5,
};
break;
default:
this.notificationService.showError("Testing not supported for this provider type");
return;
@@ -388,6 +405,8 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
return "Telegram";
case NotificationProviderType.Discord:
return "Discord";
case NotificationProviderType.Gotify:
return "Gotify";
default:
return "Unknown";
}
@@ -583,6 +602,33 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
});
}
/**
* Handle Gotify provider save
*/
onGotifySave(data: GotifyFormData): void {
if (this.modalMode === "edit" && this.editingProvider) {
this.updateGotifyProvider(data);
} else {
this.createGotifyProvider(data);
}
}
/**
* Handle Gotify provider test
*/
onGotifyTest(data: GotifyFormData): void {
const testRequest = {
serverUrl: data.serverUrl,
applicationToken: data.applicationToken,
priority: data.priority,
};
this.notificationProviderStore.testProvider({
testRequest,
type: NotificationProviderType.Gotify,
});
}
/**
* Handle provider modal cancel
*/
@@ -601,6 +647,7 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
this.showPushoverModal = false;
this.showTelegramModal = false;
this.showDiscordModal = false;
this.showGotifyModal = false;
this.showProviderModal = false;
this.editingProvider = null;
this.notificationProviderStore.clearTestResult();
@@ -948,6 +995,59 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
this.monitorProviderOperation("updated");
}
/**
* Create new Gotify provider
*/
private createGotifyProvider(data: GotifyFormData): void {
const createDto = {
name: data.name,
isEnabled: data.enabled,
onFailedImportStrike: data.onFailedImportStrike,
onStalledStrike: data.onStalledStrike,
onSlowStrike: data.onSlowStrike,
onQueueItemDeleted: data.onQueueItemDeleted,
onDownloadCleaned: data.onDownloadCleaned,
onCategoryChanged: data.onCategoryChanged,
serverUrl: data.serverUrl,
applicationToken: data.applicationToken,
priority: data.priority,
};
this.notificationProviderStore.createProvider({
provider: createDto,
type: NotificationProviderType.Gotify,
});
this.monitorProviderOperation("created");
}
/**
* Update existing Gotify provider
*/
private updateGotifyProvider(data: GotifyFormData): void {
if (!this.editingProvider) return;
const updateDto = {
name: data.name,
isEnabled: data.enabled,
onFailedImportStrike: data.onFailedImportStrike,
onStalledStrike: data.onStalledStrike,
onSlowStrike: data.onSlowStrike,
onQueueItemDeleted: data.onQueueItemDeleted,
onDownloadCleaned: data.onDownloadCleaned,
onCategoryChanged: data.onCategoryChanged,
serverUrl: data.serverUrl,
applicationToken: data.applicationToken,
priority: data.priority,
};
this.notificationProviderStore.updateProvider({
id: this.editingProvider.id,
provider: updateDto,
type: NotificationProviderType.Gotify,
});
this.monitorProviderOperation("updated");
}
/**
* Monitor provider operation completion and close modals
*/

View File

@@ -17,6 +17,7 @@ export enum NotificationProviderType {
Pushover = "Pushover",
Telegram = "Telegram",
Discord = "Discord",
Gotify = "Gotify",
}
export enum AppriseMode {

View File

@@ -0,0 +1,82 @@
---
sidebar_position: 7
---
import {
ConfigSection,
ElementNavigator,
SectionTitle,
styles
} from '@site/src/components/documentation';
# Gotify
Gotify is a simple, self-hosted push notification server. It provides a REST API for sending messages and has clients available for Android, web browsers, and CLI.
<ElementNavigator />
<div className={styles.documentationPage}>
<div className={styles.section}>
<SectionTitle icon="🔔">Configuration</SectionTitle>
<p className={styles.sectionDescription}>
Configure your Gotify server connection to receive push notifications.
</p>
<ConfigSection
title="Server URL"
icon="🌐"
id="server-url"
>
The URL of your Gotify server instance. This should be the base URL where your Gotify server is accessible.
Examples:
- `https://gotify.example.com`
- `http://192.168.1.100:8080`
</ConfigSection>
<ConfigSection
title="Application Token"
icon="🔑"
id="application-token"
>
The application token used to authenticate with your Gotify server. To create an application token:
1. Log in to your Gotify web interface
2. Go to **Apps** and click **Create Application**
3. Give your application a name (e.g., "Cleanuparr")
4. Copy the generated token and paste it here
Each application in Gotify has its own token and can be configured with a custom name and icon.
Reference: https://gotify.net/docs/
</ConfigSection>
<ConfigSection
title="Priority"
icon="🔥"
id="priority"
>
The priority level for notifications (0-10). Higher priority messages may trigger different notification behaviors on Gotify clients:
- **0**: Minimum priority - may not trigger any notification
- **1-3**: Low priority - quiet notification
- **4-7**: Normal priority - standard notification behavior
- **8-10**: High priority - may trigger more intrusive notifications
The exact behavior depends on your Gotify client configuration. Default is 5.
Reference: https://gotify.net/docs/msgextras
</ConfigSection>
</div>
</div>