diff --git a/.fallout/build.schema.json b/.fallout/build.schema.json
index e3d4d33..86ca2ac 100644
--- a/.fallout/build.schema.json
+++ b/.fallout/build.schema.json
@@ -29,8 +29,8 @@
"GitHubRelease",
"Images",
"Push",
+ "PushDevelop",
"PushDockerHub",
- "PushEdge",
"PushGhcr",
"ReleaseBundle",
"Test",
@@ -115,6 +115,10 @@
"allOf": [
{
"properties": {
+ "DockerHubNamespace": {
+ "type": "string",
+ "description": "Docker Hub namespace — the account images are mirrored under"
+ },
"ImageTag": {
"type": "string",
"description": "Image tag. Defaults to the git tag on a tag build, otherwise 'dev'"
diff --git a/.github/workflows/publish-edge.yml b/.github/workflows/publish-develop.yml
similarity index 91%
rename from .github/workflows/publish-edge.yml
rename to .github/workflows/publish-develop.yml
index 5e0a3dc..c445b96 100644
--- a/.github/workflows/publish-edge.yml
+++ b/.github/workflows/publish-develop.yml
@@ -9,12 +9,12 @@
#
# - To trigger manual generation invoke:
#
-# fallout --generate-configuration GitHubActions_publish-edge --host GitHubActions
+# fallout --generate-configuration GitHubActions_publish-develop --host GitHubActions
#
#
# ------------------------------------------------------------------------------
-name: publish-edge
+name: publish-develop
on:
push:
@@ -49,8 +49,8 @@ jobs:
global-json-file: global.json
- name: 'Restore: dotnet tools'
run: dotnet tool restore
- - name: 'Run: PushEdge'
- run: dotnet fallout PushEdge
+ - name: 'Run: PushDevelop'
+ run: dotnet fallout PushDevelop
env:
RegistryUser: ${{ secrets.REGISTRY_USER }}
RegistryPassword: ${{ secrets.REGISTRY_PASSWORD }}
diff --git a/CLAUDE.md b/CLAUDE.md
index 67f82a0..02c8c6c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -190,7 +190,7 @@ dotnet fallout Test # same thing via the tool, which is what CI invokes
> Change the attribute, then regenerate — once per workflow:
> ```bash
> dotnet fallout --generate-configuration GitHubActions_build --host GitHubActions
-> # …and publish-edge · publish-ghcr · publish-release · publish-dockerhub
+> # …and publish-develop · publish-ghcr · publish-release · publish-dockerhub
> ```
### Branching — GitFlow (2026-08-16)
@@ -200,7 +200,7 @@ tagged `v*`. Work goes `feat|fix|chore|docs/*` → PR into `develop`. A release
`release/*` window) **fast-forwarded** into `main`, then tagged. `hotfix/*` is cut from `main` and **must**
be ported back to `develop`.
-- Every non-docs push to `develop` republishes the images as `:edge` (`PushEdge`).
+- Every non-docs push to `develop` republishes the images as `:develop` (`PushDevelop`).
- `GitHubRelease` **refuses** a tag that is not reachable from `main` or `support/*` — the trunk is
never tagged for release.
- **Never merge a release PR with GitHub's button.** It rewrites the commits, which severs the
diff --git a/Directory.Packages.props b/Directory.Packages.props
index f7ecd83..545c34c 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -92,6 +92,8 @@
+
+
diff --git a/README.md b/README.md
index 1fdb48e..aa3c96b 100644
--- a/README.md
+++ b/README.md
@@ -162,6 +162,10 @@ Every response carries ``, so a client knows whe
stop. Item GUIDs are the stable episode id (`{broadcaster}:{native-id}`), unchanged by a re-crawl —
which is what stops Sonarr grabbing the same episode twice.
+Krautwatch can also create that instance for you on first boot — set `SONARR_URL` / `SONARR_API_KEY`
+(and the `RADARR_*` pair) in the environment and they are inserted while no instance is configured.
+After that the UI owns them and the variables are ignored, so a restart never undoes an edit.
+
**As a download client** — add a *SABnzbd* client pointing at the same host. Supported modes:
`version`, `get_config`, `addurl`, `addfile`, `queue`, `history`.
@@ -236,8 +240,13 @@ error: ZDF API rejected our Api-Auth key (401) — it has most likely been rotat
Set Zdf:ApiAuthKey to the current value. ZDF crawling produces nothing until then.
```
-The ZDF agent's `/health` also goes **degraded** (amber in the Aspire dashboard; still HTTP 200, so it
-won't restart-loop your container). Recovery is a config change and a restart, not a rebuild:
+**It usually fixes itself.** ZDF's own web player publishes the current key, so on a rejection
+Krautwatch reads it, checks it against the API, and carries on with it — logging loudly, and holding
+`/health` at **degraded** until you make it permanent, because the recovered key lives in memory only
+and a restart goes back to what is configured. Turn it off with `Zdf__KeyDiscovery__Enabled=false`.
+
+To make it permanent — or if discovery is off or fails — it is a config change and a restart, not a
+rebuild:
```
Zdf__ApiAuthKey= # environment variable
@@ -448,7 +457,7 @@ pinned as a local dotnet tool, so run `dotnet tool restore` once on a fresh clon
**The whole CI/CD pipeline is Fallout.** Every workflow under `.github/workflows/` is *generated*
from the `[GitHubActions]` attributes in [`build/Build.CI.GitHubActions.cs`](build/Build.CI.GitHubActions.cs),
and each one only provisions a runner and invokes a target — the gate runs `Test`, the trunk runs
-`PushEdge`, a tag runs `PushGhcr` and `GitHubRelease`. Edit the attribute, not the YAML, or your
+`PushDevelop`, a tag runs `PushGhcr` and `GitHubRelease`. Edit the attribute, not the YAML, or your
change is overwritten:
```bash
@@ -500,8 +509,8 @@ gh pr create --base develop --label enhancement # one category label — th
### Running the trunk
-Every push to `develop` republishes the images as `:edge` (multi-arch, GHCR). Point an existing
-deployment's `.env` at that tag to follow along — it moves under you, migrations included, and
+Every push to `develop` republishes the images as `:develop` (multi-arch, GHCR and Docker Hub).
+Point an existing deployment's `.env` at that tag to follow along — it moves under you, migrations included, and
downgrading back to a release is not supported, so back up first.
---
diff --git a/build/Build.CI.GitHubActions.cs b/build/Build.CI.GitHubActions.cs
index 06511e4..69bae1a 100644
--- a/build/Build.CI.GitHubActions.cs
+++ b/build/Build.CI.GitHubActions.cs
@@ -43,9 +43,10 @@
},
InvokedTargets = new[] { nameof(Test) })]
-// ── The edge channel (GitFlow's preview channel) ──────────────────────────────
+// ── The develop channel (GitFlow's preview channel) ───────────────────────────
//
-// Every push to the trunk republishes the six images under `:edge`, so a tester can run the next
+// Every push to the trunk republishes the six images under `:develop` — named after the branch
+// they are built from — so a tester can run the next
// release before it is a release. Mirrors the extension repo's rolling `preview` VSIX; the shape
// differs only because our artefact is a registry tag rather than a GitHub release asset.
//
@@ -53,16 +54,16 @@
// and rebuilding six multi-arch images because a markdown file changed is pure waste.
//
// Concurrency QUEUES rather than cancels (ConcurrencyCancelInProgress is left at its default
-// false): cancelling a push mid-way can leave `:edge` pointing at a half-written manifest list,
-// which is worse than an edge build running a few minutes behind.
+// false): cancelling a push mid-way can leave `:develop` pointing at a half-written manifest
+// list, which is worse than the channel running a few minutes behind the branch.
[GitHubActions(
- "publish-edge",
+ "publish-develop",
GitHubActionsImage.UbuntuLatest,
FetchDepth = 0,
OnPushBranches = new[] { DevelopBranch },
OnPushExcludePaths = new[] { "**/*.md", "docs/**" },
ConcurrencyGroup = "${{ github.workflow }}",
- InvokedTargets = new[] { nameof(PushEdge) },
+ InvokedTargets = new[] { nameof(PushDevelop) },
EnvironmentName = "ghcr",
ImportSecrets = new[] { nameof(RegistryUser), nameof(RegistryPassword) })]
@@ -100,7 +101,7 @@
//
// It also refuses to go out from the wrong branch: GitHubRelease asserts the tag is reachable from
// main or a support line (see Build.Release.cs). Under GitFlow the trunk is never tagged for
-// release — it ships through the edge channel instead.
+// release — it ships through the develop channel instead.
[GitHubActions(
"publish-release",
GitHubActionsImage.UbuntuLatest,
diff --git a/build/Build.Publish.cs b/build/Build.Publish.cs
index fa9ce05..c6f514e 100644
--- a/build/Build.Publish.cs
+++ b/build/Build.Publish.cs
@@ -30,11 +30,28 @@ partial class Build
/// Set by the per-registry targets; falls back to the --registry parameter.
string _targetRegistry;
+ /// Set by the per-registry targets; falls back to --registry-namespace.
+ string _targetNamespace;
+
string EffectiveRegistry => _targetRegistry ?? Registry;
+ string EffectiveNamespace => _targetNamespace ?? RegistryNamespace;
+
[Parameter("Registry namespace — the owner or organisation the images live under")]
readonly string RegistryNamespace = "chrison-dev";
+ ///
+ /// The Docker Hub account images are mirrored under.
+ ///
+ ///
+ /// Separate from because the two registries genuinely disagree: the
+ /// GitHub org is chrison-dev, and no such namespace exists on Docker Hub (its API answers 404)
+ /// — pushes there have to go to the account, chrison. Sharing one value meant the mirror could
+ /// never have worked, which went unnoticed because the channel had no credentials to fail with (#115).
+ ///
+ [Parameter("Docker Hub namespace — the account images are mirrored under")]
+ readonly string DockerHubNamespace = "chrison";
+
[Parameter("Registry username for the push")]
readonly string RegistryUser;
@@ -56,7 +73,7 @@ partial class Build
AbsolutePath ComposeDirectory => RootDirectory / ".artifacts" / "compose";
- /// Set by ; wins over every other tag resolution.
+ /// Set by ; wins over every other tag resolution.
string _tagOverride;
///
@@ -77,7 +94,7 @@ string EffectiveTag
{
get
{
- // An edge build runs off a branch, where the resolutions below would answer "dev" —
+ // A trunk build runs off a branch, where the resolutions below would answer "dev" —
// the point of the channel is that it has its own, stable, name.
if (!string.IsNullOrWhiteSpace(_tagOverride))
return _tagOverride;
@@ -127,7 +144,7 @@ static readonly (string Service, string Project, string Assembly, bool Ffmpeg)[]
static string LocalImage(string service) => $"krautwatch-{service}";
string RemoteImage(string service) =>
- $"{EffectiveRegistry}/{RegistryNamespace}/{LocalImage(service)}";
+ $"{EffectiveRegistry}/{EffectiveNamespace}/{LocalImage(service)}";
Target Compose => _ => _
.Description("Generate docker-compose.yaml + .env from the Aspire AppHost")
@@ -191,16 +208,16 @@ string RemoteImage(string service) =>
PushImages();
});
- /// The rolling tag the trunk publishes under.
+ /// The rolling tag the trunk publishes under — named after the branch it comes from.
///
/// Not "latest": that name is conventionally the newest stable image, and a compose file
- /// left on the default tag would silently follow the trunk. Anyone running :edge has
- /// typed the word.
+ /// left on the default tag would silently follow the trunk. Anyone running :develop has
+ /// typed the name of a branch, which says exactly what they are getting.
///
- const string EdgeTag = "edge";
+ const string DevelopTag = "develop";
- Target PushEdge => _ => _
- .Description($"Push the rolling ':{EdgeTag}' images to GHCR — CI target for pushes to the trunk")
+ Target PushDevelop => _ => _
+ .Description($"Push the rolling ':{DevelopTag}' images to GHCR — CI target for pushes to the trunk")
.DependsOn(Compile)
.Requires(() => RegistryUser)
.Requires(() => RegistryPassword)
@@ -208,9 +225,9 @@ string RemoteImage(string service) =>
{
// The images are the whole channel — there is no separate release artefact to attach,
// as there would be for a versioned release. A tester points their existing .env at
- // :edge (docs/releasing.md#the-edge-channel) and pulls.
+ // :develop (docs/releasing.md#the-develop-channel) and pulls.
_targetRegistry = "ghcr.io";
- _tagOverride = EdgeTag;
+ _tagOverride = DevelopTag;
PushImages();
});
@@ -222,6 +239,7 @@ string RemoteImage(string service) =>
.Executes(() =>
{
_targetRegistry = "docker.io";
+ _targetNamespace = DockerHubNamespace;
PushImages();
});
diff --git a/build/Build.Release.cs b/build/Build.Release.cs
index 20ab2fc..82ec89d 100644
--- a/build/Build.Release.cs
+++ b/build/Build.Release.cs
@@ -110,7 +110,7 @@ void AssertTagged() =>
///
///
///
- /// Under GitFlow the trunk is never tagged for release — develop ships through the edge
+ /// Under GitFlow the trunk is never tagged for release — develop ships through its own
/// channel, and a release comes from main after a stabilisation window
/// (docs/branching-and-release.md). Without this check that rule is documentation only, and
/// breaking it is silent: a v* tag anywhere publishes real images and a real release
@@ -149,7 +149,7 @@ void AssertReleasableRef()
Assert.True(releasable,
$"{ReleaseTag} is not reachable from origin/{MainBranch} or a support line — it is on " +
$"[{string.Join(", ", branches)}]. Releases are cut from {MainBranch}; the trunk ships " +
- "through the edge channel. See docs/branching-and-release.md.");
+ "through the develop channel. See docs/branching-and-release.md.");
Log.Information("{Tag} is reachable from {Branches}", ReleaseTag, string.Join(", ", branches));
}
diff --git a/docs/branching-and-release.md b/docs/branching-and-release.md
index d080fd1..79c7dc0 100644
--- a/docs/branching-and-release.md
+++ b/docs/branching-and-release.md
@@ -13,7 +13,7 @@ runs.
| Branch | Purpose | Lifetime | Tagged? |
|---|---|---|---|
-| `develop` | **Integration trunk. Default branch.** All finished work lands here first. Every push republishes the [`:edge` images](releasing.md#the-edge-channel). | Permanent | No |
+| `develop` | **Integration trunk. Default branch.** All finished work lands here first. Every push republishes the [`:develop` images](releasing.md#the-develop-channel). | Permanent | No |
| `main` | **Production.** Only receives merges from `release/*` and `hotfix/*`, and every one of those is tagged. Never committed to directly. | Permanent | **Yes** |
| `release/*` | **Stabilisation window** for a release being prepared. Cut from `develop`; takes only fixes and release prep. Merges to `main` *and back to* `develop`, then deleted. | Short-lived | No (the merge into `main` is) |
| `hotfix/*` | **Urgent production fix.** Cut from `main`. Merges to `main` *and* `develop`, then deleted. | Short-lived | No (the merge into `main` is) |
@@ -89,7 +89,7 @@ flowchart TD
A["A change needs to ship"] --> B{"Is a released version
broken right now?"}
B -->|No| C["feat/* or fix/*
off develop"]
C --> D["PR → develop"]
- D --> E["Ships on the next release
(and on :edge immediately)"]
+ D --> E["Ships on the next release
(and on :develop immediately)"]
B -->|Yes| F{"Does it affect the
version main is on?"}
F -->|Yes| G["hotfix/* off main"]
G --> H["PR → main, tag,
then merge back to develop"]
diff --git a/docs/ci.md b/docs/ci.md
index 224d975..23e4d99 100644
--- a/docs/ci.md
+++ b/docs/ci.md
@@ -18,7 +18,7 @@ it.
| File | Trigger | Invokes | Environment |
|---|---|---|---|
| `build.yml` | push to `develop`/`main`; PR into `develop`, `main`, `release/*`, `hotfix/*`, `support/*` | `Test` — the required check | — |
-| `publish-edge.yml` | push to `develop` (non-docs) | `PushEdge` — rolling `:edge` images | `ghcr` |
+| `publish-develop.yml` | push to `develop` (non-docs) | `PushDevelop` — rolling `:develop` images | `ghcr` |
| `publish-ghcr.yml` | `v*` tag | `PushGhcr` — versioned multi-arch images | `ghcr` |
| `publish-release.yml` | `v*` tag | `GitHubRelease` — compose bundle + notes | `github-release` |
| `publish-dockerhub.yml` | dispatch only | `PushDockerHub` — mirror | `dockerhub` |
@@ -29,8 +29,8 @@ flowchart LR
B --> C(["ubuntu-latest ✓"])
D["Push to develop"] --> B
- D --> E["publish-edge.yml
PushEdge"]
- E --> ET["ghcr.io/…:edge"]
+ D --> E["publish-develop.yml
PushDevelop"]
+ E --> ET["ghcr.io/…:develop"]
T["Tag v* on main"] --> G["publish-ghcr.yml
PushGhcr"]
T --> R["publish-release.yml
GitHubRelease"]
@@ -63,7 +63,7 @@ The generator names a job after its runner image, so **all five workflows produc
In practice it only shows up in one place. A PR from a working branch has a head SHA that only the
gate ran on, so its check list is clean. A **release PR from `develop`** has a head SHA that
-`publish-edge` also ran on, so the PR lists the gate's check *and* the edge publish's, both under
+`publish-develop` also ran on, so the PR lists the gate's check *and* the edge publish's, both under
the same name — and GitHub requires every check with that name to pass.
That is a defensible thing to be blocked by (don't cut a release from a commit whose images
@@ -82,23 +82,24 @@ complements forever.
The extension repo pays that price because its gate packages a VSIX. Ours is a five-minute
`dotnet test`. We pay the five minutes.
-`publish-edge.yml` *does* filter paths, and safely: it is not a required check, so a skipped run
+`publish-develop.yml` *does* filter paths, and safely: it is not a required check, so a skipped run
blocks nothing.
-## publish-edge.yml — the trunk channel
+## publish-develop.yml — the trunk channel
Every non-docs push to `develop` rebuilds the six service images for `linux/amd64` and
-`linux/arm64` and pushes them to GHCR under `:edge`, replacing what was there. See
-[releasing.md](releasing.md#the-edge-channel) for how to run it.
+`linux/arm64` and pushes them to GHCR under `:develop`, replacing what was there. See
+[releasing.md](releasing.md#the-develop-channel) for how to run it.
Two deliberate choices worth knowing when reading the attribute:
- **Concurrency queues, never cancels.** `ConcurrencyCancelInProgress` stays at its default
- `false`. A cancelled push can leave `:edge` pointing at a half-written manifest list, which is
- worse than an edge image running a few minutes behind the trunk.
-- **`edge`, not `latest`.** `latest` is conventionally the newest *stable* image, and it is what a
- compose file falls back to when a tag is omitted — so naming the trunk channel `latest` would
- silently upgrade people who never asked for it. Anyone on `:edge` typed the word.
+ `false`. A cancelled push can leave `:develop` pointing at a half-written manifest list, which is
+ worse than the channel running a few minutes behind the branch.
+- **`develop`, not `latest`.** `latest` is conventionally the newest *stable* image, and it is what
+ a compose file falls back to when a tag is omitted — so naming the trunk channel `latest` would
+ silently upgrade people who never asked for it. Anyone on `:develop` typed the name of a branch,
+ which says exactly what they are getting.
## The tag pipeline
@@ -155,8 +156,8 @@ leaking into the build definition.
Adding a registry is one more attribute plus an environment holding `REGISTRY_USER` and
`REGISTRY_PASSWORD`. No target changes.
-Neither `ghcr` nor `github-release` currently has an approval rule, so a tag ships without a human
-in the loop. If that ever stops being the right trade, add required reviewers to the environment —
+None of `ghcr`, `github-release` or `dockerhub` currently has an approval rule, so a tag ships without
+a human in the loop. If that ever stops being the right trade, add required reviewers to the environment —
not a condition in the build.
## Regenerating
@@ -165,7 +166,7 @@ After changing any `[GitHubActions]` attribute:
```bash
./build.sh --generate-configuration GitHubActions_build --host GitHubActions
-./build.sh --generate-configuration GitHubActions_publish-edge --host GitHubActions
+./build.sh --generate-configuration GitHubActions_publish-develop --host GitHubActions
./build.sh --generate-configuration GitHubActions_publish-ghcr --host GitHubActions
./build.sh --generate-configuration GitHubActions_publish-release --host GitHubActions
./build.sh --generate-configuration GitHubActions_publish-dockerhub --host GitHubActions
diff --git a/docs/releasing.md b/docs/releasing.md
index 5b0b38c..c8f4bd7 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -8,7 +8,7 @@ Channels, versioning, and the runbook for every kind of release. The branch mode
```mermaid
flowchart LR
- DEV["develop"] -->|every push| EDGE["ghcr.io/…:edge
rolling"]
+ DEV["develop"] -->|every push| EDGE["…:develop
rolling"]
MAIN["main / support/*"] -->|"v* tag"| GHCR["ghcr.io/…:0.3.0"]
MAIN -->|"v* tag"| REL["GitHub release
compose + env template"]
GHCR -.->|"manual dispatch"| DH["docker.io mirror"]
@@ -21,7 +21,7 @@ flowchart LR
| Channel | Trigger | Gating |
|---|---|---|
-| `:edge` images | every non-docs push to `develop` | none |
+| `:develop` images | every non-docs push to `develop` | none |
| versioned images + GitHub release | any `v*` tag reachable from `main`/`support/*` | the [release guard](ci.md#the-release-guard) |
| Docker Hub mirror | manual dispatch | manual by definition |
@@ -36,24 +36,25 @@ same string with the `v` stripped (`v0.3.0` → `:0.3.0`). Pre-1.0, so:
There is deliberately **no Nerdbank.GitVersioning here**, unlike the Fallout repos. They need a
computed monotonic version because every preview build publishes a numbered package to a registry
-that will not accept the same number twice. Our preview channel is a single rolling `:edge` tag with
+that will not accept the same number twice. Our preview channel is a single rolling `:develop` tag with
no number in it, so the machinery would buy nothing and cost a `version.json` to keep honest.
-## The edge channel
+## The develop channel
-Every non-docs push to `develop` republishes all six images under `:edge`. That is the whole
+Every non-docs push to `develop` republishes all six images under `:develop`, named after the branch
+they are built from. That is the whole
channel: no release object, no version, just the trunk in runnable form.
-To follow it, point an existing deployment's image tags at `edge`:
+To follow it, point an existing deployment's image tags at `develop`:
```bash
-sed -i '' 's/:[0-9]\+\.[0-9]\+\.[0-9]\+$/:edge/' .env # in a release bundle's .env
+sed -i '' 's/:[0-9]\+\.[0-9]\+\.[0-9]\+$/:develop/' .env # in a release bundle's .env
docker compose pull && docker compose up -d
```
-`:edge` moves under you — that is the point, and it is why the tag is not called `latest`. Expect a
+`:develop` moves under you — that is the point, and it is why the tag is not called `latest`. Expect a
schema migration to land there before it lands in a release; the Migrator runs to completion on
-every start, so an edge deployment upgrades itself, and **downgrading back to a release is not
+every start, so a develop deployment upgrades itself, and **downgrading back to a release is not
supported**. Take a database backup before following the trunk with data you care about.
## Cutting a release
@@ -195,8 +196,12 @@ radius of a bad release for no benefit.
gh workflow run publish-dockerhub.yml -f ImageTag=0.3.0
```
-It needs a `dockerhub` environment holding `REGISTRY_USER` and `REGISTRY_PASSWORD`; there isn't one
-yet, so the run fails fast on the missing parameter until it's created.
+It reads `REGISTRY_USER` and `REGISTRY_PASSWORD` from the `dockerhub` environment (a Docker Hub access
+token, not the account password).
+
+Note the namespace differs by registry: GHCR publishes under the GitHub org `chrison-dev`, Docker Hub
+under the account `chrison` — there is no `chrison-dev` on Docker Hub. `--docker-hub-namespace`
+overrides it.
## If a publish fails partway
diff --git a/docs/self-hosting.md b/docs/self-hosting.md
index 8ae7590..216e114 100644
--- a/docs/self-hosting.md
+++ b/docs/self-hosting.md
@@ -135,6 +135,20 @@ Once an administrator exists, `/setup` never reopens.
Point both at the **`newznab`** service on port `5055` — not the web UI.
+> **Skip the clicking.** Put your instance in `.env` before the first `docker compose up` and it is
+> created for you:
+>
+> ```dotenv
+> SONARR_URL=http://sonarr:8989
+> SONARR_API_KEY=
+> RADARR_URL= # optional, same shape
+> RADARR_API_KEY=
+> ```
+>
+> This runs **only while no instance is configured**. Once one exists — created this way or in the UI —
+> the UI owns them: editing a key or a URL is never undone by a restart, and these variables are
+> ignored. Both halves of a pair are required; a URL without a key is treated as a typo and skipped.
+
**As an indexer** (Prowlarr, or Sonarr → Settings → Indexers → *Newznab*):
| Field | Value |
@@ -410,8 +424,13 @@ error: ZDF API rejected our Api-Auth key (401) — it has most likely been rotat
and its `/health` reports **degraded** (still HTTP 200 — a restart cannot fix a rotated key, so it
deliberately does not fail the container's health probe). ARD and KiKA are unaffected.
-Fix it without waiting for a release: set `Zdf__ApiAuthKey` to the current value — the one ZDF's own
-web player sends — on the ZDF agent and the Newznab API, then restart both.
+In most cases crawling then resumes on its own: Krautwatch reads the current key off ZDF's player,
+verifies it against the API, and uses it. Health stays **degraded** while it is running on a
+discovered key, because that key is not persisted — a restart goes back to your configuration.
+
+Make it permanent by setting `Zdf__ApiAuthKey` to the value in the log, on the ZDF agent and the
+Newznab API, then restarting both. To disable the automatic recovery entirely, set
+`Zdf__KeyDiscovery__Enabled=false`.
### A settings row shows an API-key problem
diff --git a/src/Application/ApplicationServiceExtensions.cs b/src/Application/ApplicationServiceExtensions.cs
index 2e987e4..7f5ffcb 100644
--- a/src/Application/ApplicationServiceExtensions.cs
+++ b/src/Application/ApplicationServiceExtensions.cs
@@ -50,6 +50,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services
// Instance CRUD only needs IArrInstanceRepository, which every host gets from AddInfrastructure,
// so these are safe here.
services.AddScoped();
+ services.AddScoped(); // first-run env bootstrap (#5)
services.AddScoped();
services.AddScoped();
diff --git a/src/Application/Settings/BootstrapArrInstances.cs b/src/Application/Settings/BootstrapArrInstances.cs
new file mode 100644
index 0000000..25f9ad9
--- /dev/null
+++ b/src/Application/Settings/BootstrapArrInstances.cs
@@ -0,0 +1,116 @@
+using Krautwatch.Domain.Entities;
+using Krautwatch.Domain.Enums;
+using Krautwatch.Domain.Interfaces;
+using Microsoft.Extensions.Logging;
+
+namespace Krautwatch.Application.Settings;
+
+// ============================================================
+// Message
+// ============================================================
+
+/// One *arr instance described by the environment.
+public sealed record ArrInstanceSeed(ArrKind Kind, string Name, string BaseUrl, string ApiKey);
+
+///
+/// The environment variables that can describe a first *arr instance (#5), and how to read them.
+///
+///
+/// Takes a lookup rather than IConfiguration so the Application layer keeps its dependencies and
+/// this stays trivially testable. Flat SCREAMING_CASE names because these are meant to be typed into a
+/// compose .env, next to KRAUTWATCH_APIKEY and friends, rather than into appsettings.
+///
+public static class ArrBootstrapEnvironment
+{
+ public const string SonarrUrl = "SONARR_URL";
+ public const string SonarrApiKey = "SONARR_API_KEY";
+ public const string RadarrUrl = "RADARR_URL";
+ public const string RadarrApiKey = "RADARR_API_KEY";
+
+ public static IReadOnlyList ReadSeeds(Func configuration)
+ {
+ var seeds = new List();
+
+ Add(ArrKind.Sonarr, "Sonarr", SonarrUrl, SonarrApiKey);
+ Add(ArrKind.Radarr, "Radarr", RadarrUrl, RadarrApiKey);
+
+ return seeds;
+
+ void Add(ArrKind kind, string name, string urlKey, string keyKey)
+ {
+ var url = configuration(urlKey)?.Trim();
+ var apiKey = configuration(keyKey)?.Trim();
+
+ // Half a pair is a typo, not a configuration: an instance with no key cannot be contacted,
+ // and one with no URL has nothing to contact. Skipped rather than half-created.
+ if (string.IsNullOrWhiteSpace(url) || string.IsNullOrWhiteSpace(apiKey)) return;
+
+ seeds.Add(new ArrInstanceSeed(kind, name, url, apiKey));
+ }
+ }
+}
+
+// ============================================================
+// Command
+// ============================================================
+
+///
+/// Creates the first *arr instances from the environment, so a compose deployment can arrive
+/// already wired up instead of requiring a trip through Settings (#5).
+///
+///
+///
+/// It runs only while no instance exists at all. That is what keeps the UI the source of truth
+/// after first boot: once anything is configured, this never touches it again — no overwriting an
+/// edited API key, no second copy when someone corrects the URL, no re-applying on every restart.
+///
+///
+/// The consequence to know: adding these variables to an instance that already has one configured does
+/// nothing, and removing every instance in the UI makes the next restart look like a first boot again.
+/// Both follow from "first instance" being the actual feature — a convenience for arriving at a
+/// working deployment, not a second, competing configuration surface.
+///
+///
+public class BootstrapArrInstancesHandler(
+ IArrInstanceRepository instances,
+ ILogger logger)
+{
+ /// How many instances were created.
+ public async Task HandleAsync(
+ IReadOnlyList seeds,
+ CancellationToken ct = default)
+ {
+ if (seeds.Count == 0) return 0;
+
+ var existing = await instances.GetAllAsync(ct);
+ if (existing.Count > 0)
+ {
+ logger.LogDebug(
+ "{Count} *arr instance(s) already configured — leaving them alone and ignoring the " +
+ "environment.", existing.Count);
+ return 0;
+ }
+
+ var created = 0;
+
+ foreach (var seed in seeds)
+ {
+ await instances.AddAsync(new ArrInstance
+ {
+ Name = seed.Name,
+ Kind = seed.Kind,
+ BaseUrl = seed.BaseUrl,
+ ApiKey = seed.ApiKey,
+ Enabled = true,
+ }, ct);
+
+ created++;
+
+ // The key may be a secret reference (env:/file:), so it is never logged — only its source.
+ logger.LogInformation("Created {Kind} instance '{Name}' at {BaseUrl} from the environment.",
+ seed.Kind, seed.Name, seed.BaseUrl);
+ }
+
+ return created;
+ }
+}
diff --git a/src/Infrastructure/Crawling/Zdf/ZdfAuthHealthCheck.cs b/src/Infrastructure/Crawling/Zdf/ZdfAuthHealthCheck.cs
index f56d29c..b0170ab 100644
--- a/src/Infrastructure/Crawling/Zdf/ZdfAuthHealthCheck.cs
+++ b/src/Infrastructure/Crawling/Zdf/ZdfAuthHealthCheck.cs
@@ -31,7 +31,16 @@ public Task CheckHealthAsync(
var snapshot = state.Snapshot();
if (!snapshot.IsRejected)
- return Task.FromResult(HealthCheckResult.Healthy("ZDF is accepting our Api-Auth key."));
+ {
+ // Working, but not on the configured key — so it still needs saying. A restart goes back to
+ // whatever is in configuration, which is the value that was just rejected.
+ return Task.FromResult(snapshot.UsingDiscoveredKey
+ ? HealthCheckResult.Degraded(
+ "ZDF rotated its Api-Auth key; we recovered by reading the current one from ZDF's " +
+ $"player and are running on that. Set {ZdfOptions.SectionName}:" +
+ $"{nameof(ZdfOptions.ApiAuthKey)} to it — the recovered key is not persisted.")
+ : HealthCheckResult.Healthy("ZDF is accepting our Api-Auth key."));
+ }
var since = snapshot.FirstRejectionAt is { } first
? _time.GetUtcNow() - first
diff --git a/src/Infrastructure/Crawling/Zdf/ZdfAuthState.cs b/src/Infrastructure/Crawling/Zdf/ZdfAuthState.cs
index be58510..ef33e48 100644
--- a/src/Infrastructure/Crawling/Zdf/ZdfAuthState.cs
+++ b/src/Infrastructure/Crawling/Zdf/ZdfAuthState.cs
@@ -36,6 +36,32 @@ public sealed class ZdfAuthState
private int _consecutiveRejections;
private DateTimeOffset? _firstRejectionAt;
private HttpStatusCode? _lastStatusCode;
+ private string? _discoveredKey;
+
+ ///
+ /// A key read off ZDF's player and verified against the API, in force for this process (#112).
+ ///
+ ///
+ /// Deliberately not persisted. It is a recovery measure, not a second place to configure the key —
+ /// the operator is told to put the value in Zdf:ApiAuthKey, and a restart should go back to
+ /// asking what they configured rather than quietly running on something we scraped weeks ago.
+ ///
+ public string? DiscoveredKey
+ {
+ get { lock (_gate) return _discoveredKey; }
+ }
+
+ /// Adopts a key that has already been verified against the API.
+ public void AdoptDiscoveredKey(string key)
+ {
+ lock (_gate)
+ {
+ _discoveredKey = key;
+ _consecutiveRejections = 0;
+ _firstRejectionAt = null;
+ _lastStatusCode = null;
+ }
+ }
/// A request was answered — the key is good.
public void RecordSuccess()
@@ -64,7 +90,8 @@ public ZdfAuthSnapshot Snapshot()
{
lock (_gate)
{
- return new ZdfAuthSnapshot(_consecutiveRejections, _firstRejectionAt, _lastStatusCode);
+ return new ZdfAuthSnapshot(
+ _consecutiveRejections, _firstRejectionAt, _lastStatusCode, _discoveredKey is not null);
}
}
}
@@ -72,10 +99,15 @@ public ZdfAuthSnapshot Snapshot()
/// Rejections since the last success. Zero means healthy.
/// When the current run of rejections began.
/// The status the API last rejected us with.
+///
+/// True when the configured key was rejected and a key read off ZDF's player is carrying us instead —
+/// working, but the operator still needs to update configuration.
+///
public readonly record struct ZdfAuthSnapshot(
int ConsecutiveRejections,
DateTimeOffset? FirstRejectionAt,
- HttpStatusCode? LastStatusCode)
+ HttpStatusCode? LastStatusCode,
+ bool UsingDiscoveredKey = false)
{
public bool IsRejected => ConsecutiveRejections > 0;
}
diff --git a/src/Infrastructure/Crawling/Zdf/ZdfCatalogClient.cs b/src/Infrastructure/Crawling/Zdf/ZdfCatalogClient.cs
index 3ddbf90..964e0df 100644
--- a/src/Infrastructure/Crawling/Zdf/ZdfCatalogClient.cs
+++ b/src/Infrastructure/Crawling/Zdf/ZdfCatalogClient.cs
@@ -24,7 +24,8 @@ public sealed class ZdfCatalogClient(
HttpClient http,
ZdfOptions? options = null,
ZdfAuthState? authState = null,
- ILogger? logger = null)
+ ILogger? logger = null,
+ ZdfKeyDiscovery? keyDiscovery = null)
{
public const string ApiBase = "https://api.zdf.de";
private const string PlayerId = "android_native_6";
@@ -214,10 +215,16 @@ void Walk(JsonElement e)
///
private async Task GetJsonAsync(string url, CancellationToken ct)
{
+ var triedDiscovery = false;
+
for (var attempt = 1; ; attempt++)
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
- req.Headers.TryAddWithoutValidation("Api-Auth", $"Bearer {_options.ApiAuthKey}");
+
+ // A key recovered from the player outranks the configured one for this process — it is the
+ // one that currently works, and the operator has been told to make it permanent.
+ req.Headers.TryAddWithoutValidation(
+ "Api-Auth", $"Bearer {_authState.DiscoveredKey ?? _options.ApiAuthKey}");
using var resp = await http.SendAsync(req, ct);
if (resp.IsSuccessStatusCode)
@@ -228,6 +235,20 @@ void Walk(JsonElement e)
if (resp.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
+ // Before giving up: ZDF's own player knows the current key, and a rejection is exactly
+ // the moment to go and read it (#112). Only a key that has already answered 200 comes
+ // back from here, so this either fixes the crawl or changes nothing.
+ if (keyDiscovery is not null && !triedDiscovery)
+ {
+ triedDiscovery = true;
+
+ if (await keyDiscovery.TryDiscoverAsync(ct) is { } recovered)
+ {
+ _authState.AdoptDiscoveredKey(recovered);
+ continue; // same attempt number: recovery is not a retry of a failure
+ }
+ }
+
_authState.RecordRejection(resp.StatusCode, DateTimeOffset.UtcNow);
_logger.LogError(
diff --git a/src/Infrastructure/Crawling/Zdf/ZdfKeyDiscovery.cs b/src/Infrastructure/Crawling/Zdf/ZdfKeyDiscovery.cs
new file mode 100644
index 0000000..4c95608
--- /dev/null
+++ b/src/Infrastructure/Crawling/Zdf/ZdfKeyDiscovery.cs
@@ -0,0 +1,141 @@
+using System.Net.Http.Headers;
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Logging;
+
+namespace Krautwatch.Infrastructure.Crawling.Zdf;
+
+///
+/// Recovers a rotated ZDF Api-Auth key by reading the one ZDF's own web player uses (#112).
+///
+///
+///
+/// The player's page embeds it as "apiAuthToken":"…" in its server-rendered data. That is a
+/// stable place — a documented-by-observation property name at a fixed URL — rather than the
+/// hash-named JS chunk the issue feared, which is what makes this worth doing at all.
+///
+///
+/// A candidate is never adopted until it has answered 200. A scrape of an undocumented page
+/// fails by returning something plausible, and sending a garbage bearer would turn "your key is
+/// stale" into an unexplained failure inside the recovery path. Verified first, adopted second; a
+/// failed discovery leaves exactly the behaviour of a deployment without it.
+///
+///
+public sealed partial class ZdfKeyDiscovery(
+ HttpClient http,
+ ZdfOptions options,
+ TimeProvider? timeProvider = null,
+ ILogger? logger = null)
+{
+ private readonly TimeProvider _time = timeProvider ?? TimeProvider.System;
+ private readonly ILogger _logger = logger ?? Microsoft.Extensions.Logging.Abstractions
+ .NullLogger.Instance;
+
+ // One flight at a time, and not too often: the source page is several megabytes, and a fleet whose
+ // key has just rotated would otherwise pull it once per failed request.
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private DateTimeOffset _lastAttempt = DateTimeOffset.MinValue;
+
+ /// The page publishes two tokens; only this property name is the one the API expects.
+ [GeneratedRegex(@"\\?""apiAuthToken\\?""\s*:\s*\\?""(?[A-Za-z0-9._-]{16,128})\\?""")]
+ private static partial Regex TokenPattern();
+
+ /// A key that has just authenticated, or null when discovery did not produce one.
+ public async Task TryDiscoverAsync(CancellationToken ct = default)
+ {
+ if (!options.KeyDiscovery.Enabled) return null;
+
+ if (!await _gate.WaitAsync(TimeSpan.Zero, ct))
+ {
+ // Another request is already doing this. Its result lands in ZdfAuthState either way.
+ return null;
+ }
+
+ try
+ {
+ var now = _time.GetUtcNow();
+ if (now - _lastAttempt < options.KeyDiscovery.MinimumInterval) return null;
+ _lastAttempt = now;
+
+ var candidate = await ReadKeyFromPlayerAsync(ct);
+ if (candidate is null) return null;
+
+ if (candidate == options.ApiAuthKey)
+ {
+ // The player is using the key we already have, so the rejection is about something else
+ // — saying so beats reporting a "recovery" that changes nothing.
+ _logger.LogWarning(
+ "ZDF's player is using the same Api-Auth key we already send, so the rejection is " +
+ "not a rotation. Something else is refusing us.");
+ return null;
+ }
+
+ if (!await VerifyAsync(candidate, ct))
+ {
+ _logger.LogWarning("Read a candidate Api-Auth key from ZDF's player, but it was rejected " +
+ "too — not adopting it.");
+ return null;
+ }
+
+ _logger.LogWarning(
+ "Recovered from a rotated ZDF Api-Auth key by reading the current one from {Source}. " +
+ "It is in use for this process only — set {Section}:{Setting} to make it permanent.",
+ options.KeyDiscovery.SourceUrl, ZdfOptions.SectionName, nameof(ZdfOptions.ApiAuthKey));
+
+ return candidate;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogWarning(ex, "Could not read the current Api-Auth key from ZDF's player.");
+ return null;
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ private async Task ReadKeyFromPlayerAsync(CancellationToken ct)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, options.KeyDiscovery.SourceUrl);
+
+ // Asking as a browser, because that is what the page is for; a default .NET agent is the kind of
+ // thing a CDN answers differently.
+ request.Headers.UserAgent.ParseAdd(
+ "Mozilla/5.0 (compatible; Krautwatch/1.0; +https://github.com/Chrison-dev/Krautwatch)");
+
+ using var response = await http.SendAsync(request, ct);
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogWarning("{Source} answered {Status} — cannot read the current key.",
+ options.KeyDiscovery.SourceUrl, (int)response.StatusCode);
+ return null;
+ }
+
+ var html = await response.Content.ReadAsStringAsync(ct);
+ var match = TokenPattern().Match(html);
+
+ if (!match.Success)
+ {
+ // The shape changed. Worth a distinct line: this is the failure that means the scrape needs
+ // revisiting, as opposed to the API simply being down.
+ _logger.LogWarning("No apiAuthToken found in {Source}. The page's shape has probably changed.",
+ options.KeyDiscovery.SourceUrl);
+ return null;
+ }
+
+ return match.Groups["key"].Value;
+ }
+
+ /// Proves a candidate against the API before anything starts using it.
+ private async Task VerifyAsync(string candidate, CancellationToken ct)
+ {
+ using var request = new HttpRequestMessage(
+ HttpMethod.Get, $"{ZdfCatalogClient.ApiBase}/search/documents?q=heute&hasVideo=true&page=1");
+
+ request.Headers.TryAddWithoutValidation("Api-Auth", $"Bearer {candidate}");
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+
+ using var response = await http.SendAsync(request, ct);
+ return response.IsSuccessStatusCode;
+ }
+}
diff --git a/src/Infrastructure/Crawling/Zdf/ZdfOptions.cs b/src/Infrastructure/Crawling/Zdf/ZdfOptions.cs
index cb79e4e..109807e 100644
--- a/src/Infrastructure/Crawling/Zdf/ZdfOptions.cs
+++ b/src/Infrastructure/Crawling/Zdf/ZdfOptions.cs
@@ -19,4 +19,38 @@ public sealed class ZdfOptions
/// Zdf__ApiAuthKey (or Zdf:ApiAuthKey) when ZDF rotates it.
///
public string ApiAuthKey { get; set; } = DefaultApiAuthKey;
+
+ /// Recovering a rotated key without human intervention (#112).
+ public ZdfKeyDiscoveryOptions KeyDiscovery { get; set; } = new();
+}
+
+///
+/// Reading the current Api-Auth key off ZDF's own web player when ours is rejected (#112).
+///
+public sealed class ZdfKeyDiscoveryOptions
+{
+ ///
+ /// On by default.
+ ///
+ ///
+ /// The issue that proposed this expected it to be opt-in, on the assumption that the key would be
+ /// buried in a hash-named JS chunk and the scrape would rot faster than the key rotates. It is not:
+ /// the page publishes it as apiAuthToken in its own embedded data, at a stable URL. Combined
+ /// with never adopting a key that has not just answered 200, a failed discovery lands exactly where
+ /// today's behaviour already lands — a loud error and a degraded health check — so there is nothing
+ /// left for opting in to protect against.
+ ///
+ public bool Enabled { get; set; } = true;
+
+ /// The page carrying the token. Only the homepage does; the sub-pages tested did not.
+ public string SourceUrl { get; set; } = "https://www.zdf.de/";
+
+ ///
+ /// How long to wait before trying discovery again after an attempt.
+ ///
+ ///
+ /// The page is ~5 MB. Without this, every crawl in a fleet hitting a rotated key at once would each
+ /// pull it — and re-pull it on every subsequent failure.
+ ///
+ public TimeSpan MinimumInterval { get; set; } = TimeSpan.FromMinutes(15);
}
diff --git a/src/Infrastructure/InfrastructureServiceExtensions.cs b/src/Infrastructure/InfrastructureServiceExtensions.cs
index 13c3f55..04d7798 100644
--- a/src/Infrastructure/InfrastructureServiceExtensions.cs
+++ b/src/Infrastructure/InfrastructureServiceExtensions.cs
@@ -153,6 +153,9 @@ public static IServiceCollection AddZdfCrawler(
services.AddSingleton(options);
services.AddSingleton();
services.AddHttpClient();
+ // Its own client: the source page is several megabytes and unrelated to the API, so it should
+ // not share timeouts or handlers with the crawl path (#112).
+ services.AddHttpClient(http => http.Timeout = TimeSpan.FromSeconds(30));
services.AddScoped(sp =>
new ZdfBroadcasterCrawler(sp.GetRequiredService()));
diff --git a/src/Presentation/AppHost/Program.cs b/src/Presentation/AppHost/Program.cs
index b1217e1..cff5d2a 100644
--- a/src/Presentation/AppHost/Program.cs
+++ b/src/Presentation/AppHost/Program.cs
@@ -72,6 +72,14 @@
// Optional: TheTVDB matching. Absent is fine — matching degrades to titles rather than failing.
var tvdbApiKey = builder.AddParameter("tvdb-apikey", secret: true);
+// Optional: a first Sonarr/Radarr instance, so a compose deployment can arrive already wired up
+// instead of requiring a trip through Settings (#5). Applied by the Web host only while no instance
+// exists — after that the UI owns them, and these are ignored.
+var sonarrUrl = builder.AddParameter("sonarr-url", secret: false);
+var sonarrApiKey = builder.AddParameter("sonarr-apikey", secret: true);
+var radarrUrl = builder.AddParameter("radarr-url", secret: false);
+var radarrApiKey = builder.AddParameter("radarr-apikey", secret: true);
+
// Newznab + SABnzbd — the public *arr-facing surface (indexer + download client).
builder.AddProject("newznab")
.WithHttpEndpoint(port: 5055, targetPort: 8080, name: "http")
@@ -90,6 +98,10 @@
.WaitFor(db)
.WaitForCompletion(migrator)
.WithEnvironment("TvdbConfiguration__ApiKey", tvdbApiKey)
+ .WithEnvironment("SONARR_URL", sonarrUrl) // ArrBootstrapEnvironment in the Web host
+ .WithEnvironment("SONARR_API_KEY", sonarrApiKey)
+ .WithEnvironment("RADARR_URL", radarrUrl)
+ .WithEnvironment("RADARR_API_KEY", radarrApiKey)
.WithExternalHttpEndpoints();
// ──────────────────────────────────────────────────────────────
diff --git a/src/Presentation/Web/Program.cs b/src/Presentation/Web/Program.cs
index 0675775..851753f 100644
--- a/src/Presentation/Web/Program.cs
+++ b/src/Presentation/Web/Program.cs
@@ -148,10 +148,42 @@
// First-run: print the gated setup link. The token lives in memory for this process only, so it rotates
// on restart, and whoever can read the log (the operator) is the only one able to claim the instance.
if (authProvider is "local")
- await LogSetupLinkIfRequiredAsync(app);
+ await BootstrapArrInstancesAsync(app);
+await LogSetupLinkIfRequiredAsync(app);
app.Run();
+///
+/// Creates the first Sonarr/Radarr instances from SONARR_URL / SONARR_API_KEY / RADARR_* (#5), so a
+/// compose deployment can arrive already wired up.
+///
+///
+/// Here rather than in an agent because this host owns setup and the UI that supersedes it, and
+/// because exactly one process must do it. It runs only while no instance exists — see
+/// for why that is the whole guard.
+///
+static async Task BootstrapArrInstancesAsync(WebApplication app)
+{
+ var logger = app.Services.GetRequiredService().CreateLogger("Krautwatch.Setup");
+
+ var seeds = ArrBootstrapEnvironment.ReadSeeds(key => app.Configuration[key]);
+ if (seeds.Count == 0) return;
+
+ try
+ {
+ using var scope = app.Services.CreateScope();
+ var bootstrap = scope.ServiceProvider.GetRequiredService();
+
+ await bootstrap.HandleAsync(seeds);
+ }
+ catch (Exception ex)
+ {
+ // Never block startup on this — on a cold deploy the schema may not exist yet, and a UI that
+ // starts is what lets an operator fix whatever is wrong.
+ logger.LogWarning(ex, "Could not apply the *arr instances described by the environment.");
+ }
+}
+
static async Task LogSetupLinkIfRequiredAsync(WebApplication app)
{
var logger = app.Services.GetRequiredService().CreateLogger("Krautwatch.Setup");
diff --git a/tests/Application.Tests/BootstrapArrInstancesTests.cs b/tests/Application.Tests/BootstrapArrInstancesTests.cs
new file mode 100644
index 0000000..b791638
--- /dev/null
+++ b/tests/Application.Tests/BootstrapArrInstancesTests.cs
@@ -0,0 +1,136 @@
+using Krautwatch.Application.Settings;
+using Krautwatch.Domain.Entities;
+using Krautwatch.Domain.Enums;
+using Krautwatch.Domain.Interfaces;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using Shouldly;
+using Xunit;
+
+namespace Krautwatch.Application.Tests;
+
+///
+/// Creating the first Sonarr/Radarr instance from the environment (#5). The interesting behaviour is
+/// all in what it refuses to do on the second boot: the UI is the source of truth once anything is
+/// configured.
+///
+public class BootstrapArrInstancesTests
+{
+ private readonly IArrInstanceRepository _instances = Substitute.For();
+
+ private BootstrapArrInstancesHandler Sut =>
+ new(_instances, NullLogger.Instance);
+
+ [Fact]
+ public async Task Both_instances_are_created_on_an_empty_deployment()
+ {
+ GivenNoInstances();
+
+ var created = await Sut.HandleAsync(
+ [Seed(ArrKind.Sonarr, "http://sonarr:8989"), Seed(ArrKind.Radarr, "http://radarr:7878")],
+ TestContext.Current.CancellationToken);
+
+ created.ShouldBe(2);
+ await _instances.Received(2).AddAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task Nothing_is_touched_when_an_instance_already_exists()
+ {
+ // The whole guard. An operator who edited the key, renamed the instance, or corrected the URL
+ // must not have any of it undone — or duplicated — by the next restart.
+ _instances.GetAllAsync(Arg.Any())
+ .Returns([Existing("http://sonarr.local:8989")]);
+
+ var created = await Sut.HandleAsync(
+ [Seed(ArrKind.Sonarr, "http://sonarr:8989")], TestContext.Current.CancellationToken);
+
+ created.ShouldBe(0);
+ await _instances.DidNotReceive().AddAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task An_empty_environment_asks_the_database_nothing()
+ {
+ var created = await Sut.HandleAsync([], TestContext.Current.CancellationToken);
+
+ created.ShouldBe(0);
+ await _instances.DidNotReceive().GetAllAsync(Arg.Any());
+ }
+
+ [Fact]
+ public async Task The_created_instance_carries_what_the_environment_said()
+ {
+ GivenNoInstances();
+ ArrInstance? saved = null;
+ await _instances.AddAsync(Arg.Do(i => saved = i), Arg.Any());
+
+ await Sut.HandleAsync([Seed(ArrKind.Sonarr, "http://sonarr:8989")],
+ TestContext.Current.CancellationToken);
+
+ saved.ShouldNotBeNull();
+ saved.Kind.ShouldBe(ArrKind.Sonarr);
+ saved.BaseUrl.ShouldBe("http://sonarr:8989");
+ saved.ApiKey.ShouldBe("key-abc");
+ saved.Enabled.ShouldBeTrue();
+ }
+
+ // ── reading the environment ───────────────────────────────
+
+ [Fact]
+ public void Both_pairs_are_read_when_both_are_set()
+ {
+ var seeds = ArrBootstrapEnvironment.ReadSeeds(new Dictionary
+ {
+ ["SONARR_URL"] = "http://sonarr:8989",
+ ["SONARR_API_KEY"] = "s-key",
+ ["RADARR_URL"] = "http://radarr:7878",
+ ["RADARR_API_KEY"] = "r-key",
+ }.GetValueOrDefault);
+
+ seeds.Select(s => s.Kind).ShouldBe([ArrKind.Sonarr, ArrKind.Radarr]);
+ }
+
+ [Theory]
+ [InlineData("http://sonarr:8989", null)]
+ [InlineData(null, "s-key")]
+ [InlineData("http://sonarr:8989", " ")]
+ public void Half_a_pair_is_a_typo_and_is_skipped(string? url, string? apiKey)
+ {
+ // An instance with no key cannot be contacted and one with no URL has nothing to contact, so a
+ // half-configured pair is dropped rather than half-created.
+ var seeds = ArrBootstrapEnvironment.ReadSeeds(new Dictionary
+ {
+ ["SONARR_URL"] = url,
+ ["SONARR_API_KEY"] = apiKey,
+ }.GetValueOrDefault);
+
+ seeds.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Surrounding_whitespace_is_trimmed()
+ {
+ // Copy-paste out of a compose file picks up trailing spaces, and a URL with one does not connect.
+ var seeds = ArrBootstrapEnvironment.ReadSeeds(new Dictionary
+ {
+ ["SONARR_URL"] = " http://sonarr:8989 ",
+ ["SONARR_API_KEY"] = " s-key ",
+ }.GetValueOrDefault);
+
+ var seed = seeds.ShouldHaveSingleItem();
+ seed.BaseUrl.ShouldBe("http://sonarr:8989");
+ seed.ApiKey.ShouldBe("s-key");
+ }
+
+ private void GivenNoInstances() =>
+ _instances.GetAllAsync(Arg.Any()).Returns([]);
+
+ private static ArrInstanceSeed Seed(ArrKind kind, string url) =>
+ new(kind, kind.ToString(), url, "key-abc");
+
+ private static ArrInstance Existing(string url) => new()
+ {
+ Name = "Sonarr", Kind = ArrKind.Sonarr, BaseUrl = url, ApiKey = "edited-in-the-ui",
+ };
+}
diff --git a/tests/Infrastructure.Tests/Krautwatch.Infrastructure.Tests.csproj b/tests/Infrastructure.Tests/Krautwatch.Infrastructure.Tests.csproj
index 0ea2a9a..42c2d68 100644
--- a/tests/Infrastructure.Tests/Krautwatch.Infrastructure.Tests.csproj
+++ b/tests/Infrastructure.Tests/Krautwatch.Infrastructure.Tests.csproj
@@ -16,6 +16,7 @@
+
diff --git a/tests/Infrastructure.Tests/ZdfKeyDiscoveryTests.cs b/tests/Infrastructure.Tests/ZdfKeyDiscoveryTests.cs
new file mode 100644
index 0000000..f5dc661
--- /dev/null
+++ b/tests/Infrastructure.Tests/ZdfKeyDiscoveryTests.cs
@@ -0,0 +1,184 @@
+using System.Net;
+using System.Text;
+using Krautwatch.Infrastructure.Crawling.Zdf;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Time.Testing;
+using Shouldly;
+using Xunit;
+
+namespace Krautwatch.Infrastructure.Tests;
+
+///
+/// Recovering from a rotated ZDF key by reading the current one off ZDF's player (#112). The spike
+/// that preceded this found the key published as apiAuthToken in the page's own embedded data,
+/// which is what makes it worth scraping; these cover the guards around doing so.
+///
+public class ZdfKeyDiscoveryTests
+{
+ private const string Current = "aa3noh4ohz9eeboo8shiesheec9ciequ9Quah7el";
+ private const string Rotated = "ahBaeMeekaiy5ohsai4bee4ki6Oopoi5quailieb";
+
+ [Fact]
+ public async Task The_key_is_read_from_the_page_and_verified_before_being_returned()
+ {
+ var handler = new PlayerStub(pageKey: Rotated, apiAccepts: Rotated);
+
+ var discovered = await Discovery(handler).TryDiscoverAsync(TestContext.Current.CancellationToken);
+
+ discovered.ShouldBe(Rotated);
+ handler.VerifiedWith.ShouldBe(Rotated); // proved against the API, not just parsed
+ }
+
+ [Fact]
+ public async Task A_candidate_the_api_also_rejects_is_not_adopted()
+ {
+ // The failure mode that makes an undocumented scrape dangerous: it returns something
+ // plausible. Adopting it would turn "your key is stale" into an unexplained failure.
+ var handler = new PlayerStub(pageKey: "not-a-real-key", apiAccepts: Rotated);
+
+ var discovered = await Discovery(handler).TryDiscoverAsync(TestContext.Current.CancellationToken);
+
+ discovered.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task A_page_whose_shape_changed_yields_nothing_rather_than_nonsense()
+ {
+ var handler = new PlayerStub(page: "redesigned", apiAccepts: Rotated);
+
+ var discovered = await Discovery(handler).TryDiscoverAsync(TestContext.Current.CancellationToken);
+
+ discovered.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task Finding_the_key_we_already_send_is_not_a_recovery()
+ {
+ // The player agreeing with us means the rejection is about something else entirely, and
+ // "recovering" to the same value would just loop.
+ var handler = new PlayerStub(pageKey: Current, apiAccepts: Current);
+
+ var discovered = await Discovery(handler).TryDiscoverAsync(TestContext.Current.CancellationToken);
+
+ discovered.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task Discovery_is_rate_limited_so_a_fleet_cannot_stampede_a_five_megabyte_page()
+ {
+ var handler = new PlayerStub(pageKey: Rotated, apiAccepts: Rotated);
+ var time = new FakeTimeProvider();
+ var sut = Discovery(handler, time);
+
+ await sut.TryDiscoverAsync(TestContext.Current.CancellationToken);
+ var second = await sut.TryDiscoverAsync(TestContext.Current.CancellationToken);
+
+ second.ShouldBeNull();
+ handler.PageFetches.ShouldBe(1);
+
+ time.Advance(TimeSpan.FromMinutes(16));
+ await sut.TryDiscoverAsync(TestContext.Current.CancellationToken);
+
+ handler.PageFetches.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task Nothing_happens_when_it_is_switched_off()
+ {
+ var handler = new PlayerStub(pageKey: Rotated, apiAccepts: Rotated);
+ var options = new ZdfOptions { KeyDiscovery = { Enabled = false } };
+
+ var discovered = await new ZdfKeyDiscovery(new HttpClient(handler), options)
+ .TryDiscoverAsync(TestContext.Current.CancellationToken);
+
+ discovered.ShouldBeNull();
+ handler.PageFetches.ShouldBe(0);
+ }
+
+ // ── the client's side of it ───────────────────────────────
+
+ [Fact]
+ public async Task A_rejected_crawl_recovers_and_completes_instead_of_throwing()
+ {
+ var handler = new PlayerStub(pageKey: Rotated, apiAccepts: Rotated);
+ var state = new ZdfAuthState();
+ var client = new ZdfCatalogClient(new HttpClient(handler), Options(), state,
+ keyDiscovery: Discovery(handler));
+
+ // Before #112 this threw ZdfAuthRejectedException and the crawl produced nothing.
+ var episodes = await client.SearchEpisodesAsync("heute-show", TestContext.Current.CancellationToken);
+
+ episodes.ShouldBeEmpty(); // the stub's payload is empty, but it answered 200
+ state.DiscoveredKey.ShouldBe(Rotated);
+ state.Snapshot().IsRejected.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task Health_says_recovered_rather_than_healthy_so_the_operator_still_updates_config()
+ {
+ var state = new ZdfAuthState();
+ state.AdoptDiscoveredKey(Rotated);
+
+ var check = new ZdfAuthHealthCheck(state);
+ var result = await check.CheckHealthAsync(
+ new HealthCheckContext
+ {
+ Registration = new HealthCheckRegistration("zdf-auth", check, HealthStatus.Degraded, null),
+ },
+ TestContext.Current.CancellationToken);
+
+ // The recovered key is not persisted, so a restart goes back to the rejected one.
+ result.Status.ShouldBe(HealthStatus.Degraded);
+ result.Description.ShouldContain("not persisted");
+ }
+
+ private static ZdfOptions Options() => new() { ApiAuthKey = Current };
+
+ private static ZdfKeyDiscovery Discovery(PlayerStub handler, TimeProvider? time = null) =>
+ new(new HttpClient(handler), Options(), time, NullLogger.Instance);
+
+ /// Serves ZDF's player page and the API, so the whole read-verify-adopt path is exercised.
+ private sealed class PlayerStub(string? pageKey = null, string? apiAccepts = null, string? page = null)
+ : HttpMessageHandler
+ {
+ public int PageFetches { get; private set; }
+ public string? VerifiedWith { get; private set; }
+
+ protected override Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var url = request.RequestUri!.ToString();
+
+ if (url.Contains("zdf.de/", StringComparison.Ordinal) && !url.Contains("api.zdf.de"))
+ {
+ PageFetches++;
+
+ // The real page embeds it inside a JS string, so the quotes arrive backslash-escaped —
+ // and it carries a second, different token under "apiToken" that must not be picked up.
+ var body = page ?? $$"""
+
+ """;
+
+ return Task.FromResult(Json(body));
+ }
+
+ var sent = request.Headers.TryGetValues("Api-Auth", out var values)
+ ? values.First().Replace("Bearer ", "")
+ : "";
+
+ if (url.Contains("q=heute&", StringComparison.Ordinal))
+ VerifiedWith = sent;
+
+ return Task.FromResult(sent == apiAccepts
+ ? Json("""{"http://zdf.de/rels/search/results":[]}""")
+ : new HttpResponseMessage(HttpStatusCode.Unauthorized));
+ }
+
+ private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK)
+ {
+ Content = new StringContent(body, Encoding.UTF8, "application/json"),
+ };
+ }
+}
diff --git a/tests/Live.Tests/ZdfLiveTests.cs b/tests/Live.Tests/ZdfLiveTests.cs
index 9d4f4e7..d90d04d 100644
--- a/tests/Live.Tests/ZdfLiveTests.cs
+++ b/tests/Live.Tests/ZdfLiveTests.cs
@@ -18,6 +18,29 @@ public class ZdfLiveTests
static ZdfLiveTests() =>
Http.DefaultRequestHeaders.UserAgent.ParseAdd("Krautwatch/1.0 (+https://github.com/Chrison-dev/Krautwatch)");
+ [Fact]
+ public async Task The_current_Api_Auth_key_can_be_read_from_ZDFs_own_player()
+ {
+ // #112's premise, checked against the live page rather than assumed: the key is published as
+ // apiAuthToken in the page's embedded data, and what comes back authenticates. If ZDF
+ // redesigns, this fails here rather than during someone's outage.
+ var discovery = new ZdfKeyDiscovery(
+ Http,
+ // A key we will never send, so discovery treats the page's value as a rotation and
+ // verifies it end to end instead of short-circuiting on "same as configured".
+ new ZdfOptions { ApiAuthKey = "not-the-current-key" });
+
+ var key = await discovery.TryDiscoverAsync(TestContext.Current.CancellationToken);
+
+ key.ShouldNotBeNullOrWhiteSpace();
+
+ // Verified inside TryDiscoverAsync, but assert independently that it really is usable.
+ var client = new ZdfCatalogClient(Http, new ZdfOptions { ApiAuthKey = key! });
+ var episodes = await client.SearchEpisodesAsync("heute-show", TestContext.Current.CancellationToken);
+
+ episodes.ShouldNotBeEmpty();
+ }
+
[Fact]
public async Task Search_finds_HeuteShow_episodes()
{