Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/plans/2026-07-27 - arr-instance-config-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ value; only the save path accepts a plaintext key. Editing shows an empty field
This matters more than usual because **`Presentation/Web` currently has no authentication at all**
(#48). Until that lands, anyone who can reach the UI can reach this page. Masking limits it to
*writing* keys rather than *harvesting* existing ones — but it does not remove the exposure, and this
page raises the cost of leaving #48 undone. Storage is plaintext in Postgres; encryption at rest is
out of scope and belongs with #48's decisions.
page raises the cost of leaving #48 undone. Storage is plaintext in Postgres — acceptable for a POC, and now tracked for replacement by **#60**
(encryption at rest, where the real question is key management rather than the cipher).

### Test Connection is an Action that runs in a UI host

Expand Down
1 change: 1 addition & 0 deletions src/Application/ApplicationServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services
// Settings
services.AddScoped<GetSettingsHandler>();
services.AddScoped<SaveSettingsHandler>();
services.AddScoped<TestArrConnectionHandler>();

// Auth (#48) — SetupToken is a singleton so it survives for the process lifetime and is
// logged once at startup; the handlers are scoped like every other use-case.
Expand Down
64 changes: 64 additions & 0 deletions src/Application/Settings/TestArrConnection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using Krautwatch.Domain.Entities;
using Krautwatch.Domain.Interfaces;

namespace Krautwatch.Application.Settings;

// ══════════════════════════════════════════════════════════════
// Messages
// ══════════════════════════════════════════════════════════════

/// <summary>Result of a connectivity test, shaped for display.</summary>
public record ArrConnectionTestResult(bool Ok, string Message, string? Failure)
{
public static ArrConnectionTestResult From(ArrConnectionResult result) => new(
result.Ok,
result.Message,
result.Ok ? null : result.Failure.ToString());

public static ArrConnectionTestResult NotFound() =>
new(false, "That instance no longer exists.", nameof(ArrConnectionFailure.Unexpected));
}

// ══════════════════════════════════════════════════════════════
// Action (IO-driven, DR-009)
// ══════════════════════════════════════════════════════════════

/// <summary>
/// Tests connectivity to a configured Sonarr/Radarr instance and caches the outcome on the record, so the
/// settings page can show state without re-probing every instance on each page load.
/// </summary>
/// <remarks>
/// This is an **Action** — it touches the outside world — but unlike the crawl Actions it runs in a UI
/// host rather than an agent. That is a deliberate, narrow deviation from DR-009: the operator clicks a
/// button and expects an answer, and round-tripping "test this instance" through the durable bus to an
/// agent and polling for a result is far more machinery than a button warrants. Recorded in
/// <c>docs/plans/2026-07-27 - arr-instance-config-ui.md</c>.
/// </remarks>
public class TestArrConnectionHandler(IArrInstanceRepository repository, IArrClient client)
{
/// <summary>Tests a stored instance and persists the outcome.</summary>
public async Task<ArrConnectionTestResult> HandleAsync(Guid instanceId, CancellationToken ct = default)
{
var instance = await repository.GetByIdAsync(instanceId, ct);
if (instance is null)
return ArrConnectionTestResult.NotFound();

var result = await client.TestConnectionAsync(instance, ct);

instance.LastTestedAt = DateTimeOffset.UtcNow;
instance.LastTestOk = result.Ok;
instance.LastTestMessage = result.Message;
await repository.UpdateAsync(instance, ct);

return ArrConnectionTestResult.From(result);
}

/// <summary>
/// Tests unsaved details, so the operator can verify before committing a record — and so a wrong key
/// never has to be persisted just to discover it is wrong. Nothing is written.
/// </summary>
public async Task<ArrConnectionTestResult> HandleAsync(
ArrInstance unsaved,
CancellationToken ct = default) =>
ArrConnectionTestResult.From(await client.TestConnectionAsync(unsaved, ct));
}
153 changes: 153 additions & 0 deletions src/Infrastructure/Arr/ArrHttpClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
using System.Net;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Text.Json;
using Krautwatch.Domain.Entities;
using Krautwatch.Domain.Interfaces;
using Microsoft.Extensions.Logging;

namespace Krautwatch.Infrastructure.Arr;

/// <summary>
/// Talks to a Sonarr/Radarr instance over its v3 API. Both expose the same
/// <c>GET /api/v3/system/status</c> shape, authenticated with an <c>X-Api-Key</c> header.
/// </summary>
/// <remarks>
/// The point of this class is that failures are **classified**, not just reported. "It doesn't work" is
/// the most common self-hosting complaint and the operator can only act on a specific cause: a wrong port
/// looks nothing like a wrong API key, and a reverse-proxy subpath left off the base URL looks nothing
/// like either. See <see cref="ArrConnectionFailure"/>.
/// </remarks>
public class ArrHttpClient(HttpClient http, ILogger<ArrHttpClient> logger) : IArrClient
{
private const string StatusPath = "api/v3/system/status";

public async Task<ArrConnectionResult> TestConnectionAsync(
ArrInstance instance,
CancellationToken ct = default)
{
if (!TryBuildStatusUri(instance.BaseUrl, out var uri))
return ArrConnectionResult.Fail(
ArrConnectionFailure.Unexpected,
$"'{instance.BaseUrl}' is not a valid absolute URL.");

try
{
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
// The *arr apps accept the key as a header or a query parameter. Header, so it never lands
// in a proxy access log.
request.Headers.TryAddWithoutValidation("X-Api-Key", instance.ApiKey);

using var response = await http.SendAsync(request, ct);

if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
return ArrConnectionResult.Fail(
ArrConnectionFailure.Unauthorized,
"Reached the instance, but the API key was rejected.");

if (response.StatusCode == HttpStatusCode.NotFound)
return ArrConnectionResult.Fail(
ArrConnectionFailure.ApiNotFound,
$"No API at {uri}. If it sits behind a reverse proxy on a subpath, include that "
+ "path in the base URL.");

if (!response.IsSuccessStatusCode)
return ArrConnectionResult.Fail(
ArrConnectionFailure.Unexpected,
$"Unexpected response {(int)response.StatusCode} {response.ReasonPhrase}.");

return await ReadStatusAsync(response, uri, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw; // the caller gave up; not an instance failure
}
catch (OperationCanceledException)
{
// HttpClient surfaces its own timeout as a cancellation, not a TimeoutException.
return ArrConnectionResult.Fail(
ArrConnectionFailure.Unreachable,
"Timed out. Check the host and port, and that the instance is running.");
}
catch (HttpRequestException ex)
{
return Classify(ex, uri);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Unexpected failure testing {BaseUrl}.", instance.BaseUrl);
return ArrConnectionResult.Fail(ArrConnectionFailure.Unexpected, ex.Message);
}
}

private static async Task<ArrConnectionResult> ReadStatusAsync(
HttpResponseMessage response,
Uri uri,
CancellationToken ct)
{
try
{
using var doc = await JsonDocument.ParseAsync(
await response.Content.ReadAsStreamAsync(ct), cancellationToken: ct);

// A 200 from something that isn't an *arr — typically the wrong port entirely, landing on some
// other web app that happily returns a page.
if (doc.RootElement.ValueKind != JsonValueKind.Object
|| !doc.RootElement.TryGetProperty("appName", out var appName)
|| !doc.RootElement.TryGetProperty("version", out var version))
{
return ArrConnectionResult.Fail(
ArrConnectionFailure.NotAnArrInstance,
$"{uri} answered, but not with a Sonarr/Radarr status. Is that the right port?");
}

return ArrConnectionResult.Success(
appName.GetString() ?? "unknown",
version.GetString() ?? "unknown");
}
catch (JsonException)
{
return ArrConnectionResult.Fail(
ArrConnectionFailure.NotAnArrInstance,
$"{uri} answered with something that isn't JSON. Is that the right port?");
}
}

/// <summary>Maps transport-level failures onto causes an operator can act on.</summary>
private static ArrConnectionResult Classify(HttpRequestException ex, Uri uri)
{
// TLS problems hide inside the exception chain, and are common with self-signed certificates.
for (Exception? inner = ex; inner is not null; inner = inner.InnerException)
{
if (inner is AuthenticationException)
return ArrConnectionResult.Fail(
ArrConnectionFailure.TlsFailure,
$"TLS handshake with {uri.Host} failed — often a self-signed or expired certificate.");

if (inner is SocketException socket)
return ArrConnectionResult.Fail(
ArrConnectionFailure.Unreachable,
$"Could not reach {uri.Host}:{uri.Port} ({socket.SocketErrorCode}).");
}

return ArrConnectionResult.Fail(ArrConnectionFailure.Unreachable, ex.Message);
}

/// <summary>
/// Builds the status URL, preserving any subpath in the configured base URL — instances behind a
/// reverse proxy are commonly served from something like <c>https://host/sonarr</c>.
/// </summary>
internal static bool TryBuildStatusUri(string baseUrl, out Uri uri)
{
uri = null!;
if (string.IsNullOrWhiteSpace(baseUrl)
|| !Uri.TryCreate(baseUrl.Trim(), UriKind.Absolute, out var parsed)
|| (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
return false;

// A trailing slash matters to Uri's relative resolution: without it the last path segment would be
// replaced rather than appended, silently dropping the subpath.
var basePath = parsed.AbsoluteUri.EndsWith('/') ? parsed.AbsoluteUri : parsed.AbsoluteUri + "/";
return Uri.TryCreate(new Uri(basePath), StatusPath, out uri!);
}
}
17 changes: 17 additions & 0 deletions src/Infrastructure/InfrastructureServiceExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Krautwatch.Domain.Interfaces;
using Krautwatch.Domain.Options;
using Krautwatch.Infrastructure.Arr;
using Krautwatch.Infrastructure.Auth;
using Krautwatch.Infrastructure.Catalog;
using Krautwatch.Infrastructure.Catalog.MediathekView;
Expand Down Expand Up @@ -134,6 +135,22 @@ public static IServiceCollection AddZdfCrawler(this IServiceCollection services)
return services;
}

/// <summary>
/// Registers the outbound Sonarr/Radarr client (#4). Needed by any host that tests instance
/// connectivity or (per #6) pre-warms the crawl list from a monitored-series list.
/// </summary>
public static IServiceCollection AddArrClient(this IServiceCollection services)
{
services.AddHttpClient<IArrClient, ArrHttpClient>(http =>
{
// Short and explicit: this sits behind an operator clicking "Test", so a hung connection has
// to fail fast rather than leave the button spinning. HttpClient's 100s default is far too
// long for that.
http.Timeout = TimeSpan.FromSeconds(10);
});
return services;
}

/// <summary>
/// Registers the Wolverine-backed <see cref="IMessageDispatcher"/> (DR-009 §5). Call only from a
/// host that has configured Wolverine (<c>UseWolverine</c>) — i.e. the crawl agents.
Expand Down
95 changes: 95 additions & 0 deletions tests/Application.Tests/TestArrConnectionHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using Krautwatch.Application.Settings;
using Krautwatch.Domain.Entities;
using Krautwatch.Domain.Enums;
using Krautwatch.Domain.Interfaces;
using NSubstitute;
using Shouldly;
using Xunit;

namespace Krautwatch.Application.Tests;

public class TestArrConnectionHandlerTests
{
private static ArrInstance Instance() => new()
{
Id = Guid.NewGuid(), Name = "Sonarr", Kind = ArrKind.Sonarr,
BaseUrl = "http://sonarr:8989", ApiKey = "key-abc",
};

[Fact]
public async Task Caches_a_successful_outcome_on_the_instance()
{
var repo = Substitute.For<IArrInstanceRepository>();
var client = Substitute.For<IArrClient>();
var instance = Instance();
repo.GetByIdAsync(instance.Id, Arg.Any<CancellationToken>()).Returns(instance);
client.TestConnectionAsync(instance, Arg.Any<CancellationToken>())
.Returns(ArrConnectionResult.Success("Sonarr", "4.0.10"));

var result = await new TestArrConnectionHandler(repo, client)
.HandleAsync(instance.Id, TestContext.Current.CancellationToken);

result.Ok.ShouldBeTrue();
result.Message.ShouldBe("Sonarr 4.0.10");
result.Failure.ShouldBeNull();

// Cached so the settings page can render state without re-probing every instance.
instance.LastTestOk.ShouldBe(true);
instance.LastTestMessage.ShouldBe("Sonarr 4.0.10");
instance.LastTestedAt.ShouldNotBeNull();
await repo.Received(1).UpdateAsync(instance, Arg.Any<CancellationToken>());
}

[Fact]
public async Task Caches_a_failure_with_its_specific_cause()
{
var repo = Substitute.For<IArrInstanceRepository>();
var client = Substitute.For<IArrClient>();
var instance = Instance();
repo.GetByIdAsync(instance.Id, Arg.Any<CancellationToken>()).Returns(instance);
client.TestConnectionAsync(instance, Arg.Any<CancellationToken>()).Returns(
ArrConnectionResult.Fail(ArrConnectionFailure.Unauthorized, "API key was rejected."));

var result = await new TestArrConnectionHandler(repo, client)
.HandleAsync(instance.Id, TestContext.Current.CancellationToken);

result.Ok.ShouldBeFalse();
result.Failure.ShouldBe("Unauthorized"); // specific, so the UI can say something useful
instance.LastTestOk.ShouldBe(false);
await repo.Received(1).UpdateAsync(instance, Arg.Any<CancellationToken>());
}

[Fact]
public async Task Reports_a_missing_instance_without_calling_out()
{
var repo = Substitute.For<IArrInstanceRepository>();
var client = Substitute.For<IArrClient>();
repo.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>()).Returns((ArrInstance?)null);

var result = await new TestArrConnectionHandler(repo, client)
.HandleAsync(Guid.NewGuid(), TestContext.Current.CancellationToken);

result.Ok.ShouldBeFalse();
await client.DidNotReceive().TestConnectionAsync(Arg.Any<ArrInstance>(), Arg.Any<CancellationToken>());
await repo.DidNotReceive().UpdateAsync(Arg.Any<ArrInstance>(), Arg.Any<CancellationToken>());
}

[Fact]
public async Task Testing_unsaved_details_writes_nothing()
{
// So a wrong key never has to be persisted just to find out it is wrong.
var repo = Substitute.For<IArrInstanceRepository>();
var client = Substitute.For<IArrClient>();
var unsaved = Instance();
client.TestConnectionAsync(unsaved, Arg.Any<CancellationToken>())
.Returns(ArrConnectionResult.Success("Radarr", "5.2"));

var result = await new TestArrConnectionHandler(repo, client)
.HandleAsync(unsaved, TestContext.Current.CancellationToken);

result.Ok.ShouldBeTrue();
result.Message.ShouldBe("Radarr 5.2");
await repo.DidNotReceive().UpdateAsync(Arg.Any<ArrInstance>(), Arg.Any<CancellationToken>());
await repo.DidNotReceive().AddAsync(Arg.Any<ArrInstance>(), Arg.Any<CancellationToken>());
}
}
Loading