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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
<ItemGroup Label="Extensions">
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
</ItemGroup>

Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,32 @@ The token is required — `/setup` is closed without it, so nobody on your netwo
before you do. It lives in memory only and rotates if the process restarts. Once an administrator exists,
`/setup` never reopens.

### What gets searched (query-driven, DR-011)

A Newznab search for a show nothing has crawled yet **resolves it live** against the broadcasters, so
Krautwatch works with no `*arr` configuration at all.

**How the first search behaves is your choice** (a setting, editable in the UI — not a rebuild):

| Mode | Behaviour |
|---|---|
| **Return results fast** *(default)* | Answer after a short wait with whatever has resolved so far, and let the crawl finish in the background. The first search may under-report; the next one is complete and instant. Advanced: set the wait in seconds (1–300, default 8). |
| **Wait for complete result on first query** | Wait for the resolution to finish so the first search is already complete. Slower — and if it exceeds Sonarr's own indexer timeout, Sonarr may treat the indexer as failing. Still bounded by `CrawlTimeout`; no wait is ever unbounded. |

Operational knobs stay in config:

```
Indexing:OnDemandResolution:Enabled # default true — kill switch
Indexing:OnDemandResolution:CrawlTimeout # default 00:02:00 — background crawl budget,
# and the ceiling on "wait for complete"
Indexing:OnDemandResolution:PositiveTtl # default 06:00:00 — trust a hit this long
Indexing:OnDemandResolution:NegativeTtl # default 00:45:00 — trust a miss this long
Indexing:OnDemandResolution:MaxConcurrentResolutions # default 2 — politeness cap toward ARD/ZDF
```

The RSS feed (no query) is never resolved — it serves the standing crawl list, since RSS-Sync polls
constantly with no particular target.

### Downloads

`Download:Directory` sets the output path (the dev fleet points it at a temp dir; in production it's
Expand Down
111 changes: 111 additions & 0 deletions docs/plans/2026-07-28 - query-driven-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# 2026-07-28 — Query-driven search: resolve `t=tvsearch` on demand (#58)

**Status:** planned · **Milestone:** ARD and KiKA indexer · **Implements:** [DR-011](../architecture/DR-011-search-driven-indexing.md)

`SearchReleasesHandler` reads only `IEpisodeRepository`, and the crawl list is three hardcoded seed shows
(`extra 3`, `Die Biene Maja`, `heute-show`). So Sonarr searching for anything else gets an empty feed —
indistinguishable from "not available in the Mediathek". DR-011 makes on-demand resolution the target,
because that is what lets Krautwatch work with **zero** `*arr` configuration.

## Where resolution runs, and why that matters

The Newznab host currently registers **no broadcaster crawlers** — only the agents do
(`AddArdCrawler`/`AddZdfCrawler` are absent from `Api/NewznabIndexerApi/Program.cs`). Live resolution
therefore needs one of:

1. **In-process in the API host** — register the crawlers there and call the port directly.
2. **Dispatch to an agent over the durable bus** and poll for completion.

**Decision: (1).** Option 2 puts a message round-trip plus polling inside Sonarr's HTTP request, for a
result that is needed synchronously — far more latency and machinery than the problem warrants. This makes
the API host run an IO-driven Action, which is the same narrow DR-009 deviation already recorded for
`TestArrConnection`: Actions are *supposed* to live on agents, but a synchronous request/response cannot
wait on a bus. Recorded here rather than discovered later.

## Slice isolation forces a small duplication

The obvious move — call `Crawling`'s `CrawlShowHandler` from `Indexing` — is **forbidden** by the
`Slice_does_not_depend_on_sibling_slices` architecture test, and rightly so. `Indexing` gets its own Action
using the `IBroadcasterCrawler` port and `IEpisodeRepository` directly. The "crawl then upsert" shape
repeats; that is the intended cost of slice isolation, not an accident to refactor away.

## Shape

### 1. Resolution cache (`ResolvedQuery`)

Without a marker of "we already looked", every repeat search re-crawls ARD. Sonarr retries the *same*
failing query on a schedule, so **negative caching matters more than positive** here.

- `ResolvedQuery` entity: normalised query text (key), `LastAttemptedAt`, `ResultCount`, `ProvidersTried`.
- TTLs, configurable: **positive 6h**, **negative 45m**. Rationale: public-TV episodes appear on
broadcast schedules, not continuously, so 6h is ample; 45m keeps a mistyped or genuinely-absent show from
hammering ARD every RSS-Sync cycle while still recovering within an hour of the show appearing.
- Migration `AddResolvedQueries`.

### 2. On-demand resolution Action (`Application/Indexing`)

- Fan out to **all** registered crawlers concurrently (ard, kika, zdf) under one shared deadline, since we
cannot know which broadcaster carries a title.
- **Bound the wait, not the crawl.** The request waits a configurable deadline (default 8s) and then
serves whatever has landed — but the crawl **keeps running in the background** to completion, so the next
call (search or RSS) gets the full set. This is the key decision: abandoning a half-finished crawl would
throw away work already paid for in ARD round-trips, and would leave the cache permanently partial.
- **Therefore the crawl must not use the request's CancellationToken.** It runs on a queue drained by a
hosted service, tied to the *host* lifetime (`ApplicationStopping`) with its own longer budget. The
request merely awaits a completion signal up to its deadline. Getting this wrong — passing the request
token through — would silently cancel every crawl the instant the response is written.
- **Coalesce** concurrent identical queries: the in-flight table means a Sonarr library refresh issuing the
same query twice crawls once, and a second caller simply waits on the first one's signal. Per-process
only — several API replicas would each crawl once, which is acceptable and not worth distributed locking.
- **Outbound politeness:** cap concurrent background resolutions so a library refresh cannot become a crawl
storm against ARD.

### 3. Wire into search

`SearchReleasesHandler`: on a DB miss for a non-empty `q`, and if the resolution cache is stale, resolve →
upsert → re-read → serve in the same response. The RSS path (`q` empty) is untouched — per DR-011 it keeps
serving the standing crawl list.

## Decisions

**Everything is configurable** under `Indexing:OnDemandResolution` — the whole point is that an operator on
a slow link or a fast LAN can tune it without a rebuild:

> **Revised during implementation:** how long to wait is not ours to decide — it is a genuine trade-off
> between a complete first answer and a responsive indexer, so it became a **user setting** on `AppSettings`
> (`SearchWaitMode` = `ReturnFast` | `WaitForComplete`, plus `SearchWaitSeconds` as an advanced value for the
> fast mode). It lives in the database rather than config precisely so the settings page can own it.
> `WaitForComplete` is still capped by `CrawlTimeout` — an unbounded wait would hang the request on a stuck
> crawl.

| Setting | Default | Purpose |
|---|---|---|
| `Enabled` | `true` | Kill switch |
| `CrawlTimeout` | `00:02:00` | Budget for the background crawl, independent of the request |
| `PositiveTtl` | `06:00:00` | How long a successful resolution is trusted |
| `NegativeTtl` | `00:45:00` | How long an empty result is trusted |
| `MaxConcurrentResolutions` | `2` | Politeness cap on outbound crawling |

**Only `q` searches resolve, never the RSS feed.** RSS-Sync polls constantly with no query; resolving there
would mean crawling on a timer for no defined target.

**A miss still returns 200 with an empty feed**, never an error. Sonarr treats indexer errors as an
availability problem and will disable the indexer after repeated failures — "no results" and "broken" must
stay distinguishable.

**Deliberately out of scope: adding resolved shows to the standing crawl list.** It is tempting (a show you
searched for should keep producing episodes in RSS), but it grows the crawl list without bound and needs its
own retention policy. Filed separately rather than smuggled in.

## Tests

- Resolution cache: fresh positive suppresses a crawl; stale positive re-crawls; fresh **negative**
suppresses (the Sonarr-retry case); stale negative re-crawls.
- Coalescing: two concurrent identical queries produce **one** crawl.
- Deadline: a crawler that never returns does not hang the search; whatever is in the DB is served.
- **The background crawl survives the response.** A crawl that finishes *after* the request deadline still
persists its episodes, so the next call sees the full set — this is the behaviour that would break if the
request token were threaded into the crawl, and it is the single most important test here.
- A crawler throwing does not fail the whole search — the others still contribute.
- RSS path (`q` empty) never triggers resolution.
- A miss returns an empty result rather than throwing.
16 changes: 16 additions & 0 deletions src/Application/ApplicationServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,20 @@ public static IServiceCollection AddApplication(this IServiceCollection services

return services;
}

/// <summary>
/// Enables query-driven search (#58 / DR-011): a Newznab search for a show no crawler has visited yet
/// resolves it on demand. Call only from a host that also registers broadcaster crawlers — without them
/// there is nothing to resolve against.
/// </summary>
public static IServiceCollection AddOnDemandResolution(
this IServiceCollection services, OnDemandResolutionOptions options)
{
// Singletons: the coalescing state and the queue must be shared process-wide, which is also why
// OnDemandResolver takes IServiceScopeFactory rather than a scoped repository.
services.AddSingleton(options);
services.AddSingleton<OnDemandResolver>();
services.AddHostedService<OnDemandResolutionService>();
return services;
}
}
Loading