Skip to content

feat(arr): outbound Sonarr/Radarr client with classified failures + Test Connection (#4) - #61

Merged
ChrisonSimtian merged 1 commit into
mainfrom
feat/arr-client
Jul 27, 2026
Merged

feat(arr): outbound Sonarr/Radarr client with classified failures + Test Connection (#4)#61
ChrisonSimtian merged 1 commit into
mainfrom
feat/arr-client

Conversation

@ChrisonSimtian

Copy link
Copy Markdown
Contributor

PR 2 of 3 for #4. Implements IArrClient against the *arr v3 API (GET /api/v3/system/status, X-Api-Key header) plus the Action that drives it from the UI.

Failures are classified, not just reported

That's the whole point of this client. "It doesn't work" is the most common self-hosting complaint, and only a specific cause is actionable — a wrong port looks nothing like a wrong API key, and a reverse-proxy subpath omitted from the base URL looks nothing like either.

Cause Detected from Message points at
Unauthorized 401 / 403 the API key
ApiNotFound 404 a missing reverse-proxy subpath
NotAnArrInstance 200 with no appName/version, or not JSON the port
TlsFailure AuthenticationException anywhere in the chain self-signed / expired cert
Unreachable SocketException, or timeout host and port
Unexpected anything else never silently treated as success

Three details worth a look

Base URLs keep their subpath. Instances behind a reverse proxy are commonly served from https://host/sonarr, and Uri's relative resolution drops the last segment unless the base ends in a slash. Without handling that, /sonarr/api/v3/status silently becomes /api/v3/status — producing a 404 that looks like a wrong path when the config was actually fine. Asserted both ways.

The API key goes in the header, not the query string, so it never lands in a proxy access log. There's a test asserting the key does not appear in the URI.

Timeout is 10s, not HttpClient's 100s default. This sits behind an operator clicking Test, so a hung connection must fail fast rather than leave the button spinning for a minute and a half.

The Action

TestArrConnectionHandler caches the outcome on the instance, so the settings page renders state without re-probing everything on load. It has an overload for unsaved details — so a wrong key never has to be persisted just to discover it's wrong.

Caller cancellation propagates rather than being recorded as an instance failure: a cancelled page load is not a broken Sonarr, and caching it as one would be actively misleading.

This is an Action running in a UI host — the deliberate, narrow DR-009 deviation recorded in the plan. The operator clicks a button and expects an answer; 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.

Testing

Against a stubbed HttpMessageHandler, not a live test — Live.Tests hits real broadcasters and there's no real Sonarr to point at. Covers every failure mode above, plus subpath preservation, trailing-slash handling, header placement, malformed base URLs failing without leaving the process, and cancellation propagation.

Tests: App 71 / Arch 11 / Infra 102 green, 0 warnings.

Also

Points the plan's plaintext-key note at #60, filed to replace plaintext *arr key storage with encryption at rest. Worth noting the real question there is key management, not the cipher — and that encryption at rest protects database dumps and stolen volumes, not a compromised app host, since the app must be able to decrypt.

Next

PR 3 — the /settings page: instance CRUD with a per-row Test button, plus the download settings whose GetSettings/SaveSettings handlers are registered in DI but called by nothing.

Plan: docs/plans/2026-07-27 - arr-instance-config-ui.md

🤖 Generated with Claude Code

PR 2 of the configuration work. Implements IArrClient against the *arr v3 API
(GET /api/v3/system/status, X-Api-Key header) and the Action that drives it from the UI.

The point of ArrHttpClient is that failures are *classified*, not merely reported.
"It doesn't work" is the most common self-hosting complaint and only a specific cause
is actionable — a wrong port looks nothing like a wrong API key, and a reverse-proxy
subpath omitted from the base URL looks nothing like either:

- Unauthorized      401/403 — reached it, key rejected
- ApiNotFound       404 — says so, and names the subpath as the likely cause
- NotAnArrInstance  200 but no appName/version, or not JSON at all — wrong port
- TlsFailure        AuthenticationException anywhere in the exception chain
- Unreachable       SocketException, or HttpClient's timeout (which surfaces as a
                    cancellation, not a TimeoutException)
- Unexpected        anything else, never silently treated as success

Two details worth knowing:

Base URLs keep their subpath. Instances behind a reverse proxy are commonly served from
something like https://host/sonarr, and Uri's relative resolution drops the last segment
unless the base ends in a slash — so a missing trailing slash would silently turn
/sonarr/api/v3/... into /api/v3/..., producing a 404 that looks like a wrong path.

The API key goes in the header rather than as a query parameter, so it never lands in a
proxy access log. Asserted in a test.

Timeout is 10s, not HttpClient's 100s default: this sits behind an operator clicking
"Test", so a hung connection has to fail fast rather than leave the button spinning.

TestArrConnectionHandler caches the outcome on the instance, so the settings page can
render state without re-probing everything on each page load, and has an overload for
*unsaved* details — so a wrong key never has to be persisted just to discover it is
wrong. Caller cancellation propagates rather than being recorded as an instance failure;
a cancelled page load is not a broken Sonarr.

This is an Action running in a UI host, which is the deliberate narrow deviation from
DR-009 recorded in the plan: the operator clicks a button and expects an answer, and
round-tripping "test this instance" through the durable bus to an agent is far more
machinery than a button warrants.

Tested against a stubbed HttpMessageHandler rather than as a live test — Live.Tests hits
real broadcasters and there is no real Sonarr to point at. Covers every failure mode
above plus subpath preservation, trailing-slash handling, header placement, malformed
base URLs failing without leaving the process, and cancellation propagation.

Also points the plan's plaintext-key note at #60, filed to replace plaintext storage with
encryption at rest.

Tests: App 71 / Arch 11 / Infra 102 green, 0 warnings.

Next: PR 3 — the /settings page, which also adopts the GetSettings/SaveSettings handlers
that are registered in DI but called by nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ChrisonSimtian
ChrisonSimtian merged commit edeea78 into main Jul 27, 2026
1 check passed
@ChrisonSimtian
ChrisonSimtian deleted the feat/arr-client branch July 27, 2026 22:29
ChrisonSimtian added a commit that referenced this pull request Jul 28, 2026
…masking (#63)

Two bugs, and the second was hiding the first.

## 1. No framework JavaScript, so the UI was inert

  GET /_framework/blazor.web.js -> HTTP 500, 0 bytes
  GET /app.css (compressed)     -> HTTP 200, 0 bytes

  System.IO.FileNotFoundException: Could not find file
    '.../src/Presentation/Web/wwwroot/_framework/blazor.web.js'
     at StaticAssetDevelopmentRuntimeHandler.AttachRuntimePatching

No Blazor circuit could start, so every interactive page — Search, Activity, and the new
Settings page — rendered its initial HTML and then did nothing. Compressed CSS came back
empty too, so the UI was unstyled. Nothing logged an obvious error; it just looked broken.

Root cause: the framework calls UseStaticWebAssets() only in Development, and none of our
hosts run as Development — there are no launch profiles, and Aspire does not set one either.
Without it the static-web-assets manifest is never loaded, so relative asset paths resolve
against wwwroot, where framework assets and the content-hashed compressed files do not
exist. The manifest itself was correct all along: blazor.web.js pointed at the
microsoft.aspnetcore.app.internal.assets package and app.css.gz at
obj/<config>/net10.0/compressed/<hash>.gz.

Fixed by calling builder.WebHost.UseStaticWebAssets() explicitly. It is a no-op when the
manifest is absent, i.e. in a published app where assets are copied into wwwroot, so
production behaviour is unchanged.

Rejected alternative: adding launchSettings.json with ASPNETCORE_ENVIRONMENT=Development to
each host. It fixes the standalone case but is the wrong lever — it would also flip
Development on for anything that runs the built binary, and an applicationUrl in those
profiles interfered with Aspire's own endpoint allocation. Fixing the asset loading directly
is narrower and works regardless of environment.

## 2. IArrClient was never registered anywhere

Trying to run in Development to test the theory surfaced this:

  Unable to resolve service for type 'Krautwatch.Domain.Interfaces.IArrClient'
    while attempting to activate 'TestArrConnectionHandler'

AddApplication() registered TestArrConnectionHandler unconditionally, but no host called
AddArrClient(), so IArrClient had no registration at all. Every host would fail to start
under DI validate-on-build, and in Production — where validation is off — the Test button
would simply have thrown at the moment a user clicked it.

Mine, from #61. Fixed by following the pattern this codebase already uses for
RunDownloadHandler ("registered by the Downloader host only — it needs IDownloadProvider"):
the handler is registered by the Web host, alongside AddArrClient(), because the two cannot
be separated.

Worth noting why this went unnoticed: running as Production disables DI validate-on-build,
so the very bug that made the UI inert also suppressed the startup error that would have
pointed at the missing registration.

## Verified on both run paths

Standalone (dotnet run --project src/Presentation/Web):
  blazor.web.js  plain 200/200645b   gz 200/55644b
  app.css        plain 200/4798b     gz 200/4831b

Aspire fleet (dotnet run --project src/Presentation/AppHost), on a clean environment:
  Web host on :51578
  blazor.web.js  plain 200/200645b   gz 200/55644b
  app.css        plain 200/4798b     gz 200/4831b

Both were 500/0b and 200/0b before. The Web host also now starts under DI validation.

Tests: App 88 / Arch 11 / Infra 102 green, 0 warnings.

Closes #63.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant