# Auto-Tuning: generate channels automatically from library metadata (#69)

**Status:** Design approved 2026-07-16. Implementation pending.
**Issue:** [ersatztv#69](http://192.168.1.95:3000/timothy/ersatztv/issues/69)
**Depends on (shipped):** #63 composite create-channel-from-lineup, #64 Channel Templates.

## Goal

"Install it and it builds a lineup for you." A creation mode that auto-generates whole
channels from existing library metadata — a second, automatic-first path alongside the
manual create-channel builder (epic #62). A large library becomes a full channel lineup
with little manual work.

Inspired by **PseudoTV Live's** signature Auto-Tuning feature. We deliberately fix its two
biggest weaknesses:
- PseudoTV is **all-or-nothing per category** with **no preview/selection**. We add a
  preview-and-select step (this is written into #69's body).
- PseudoTV **wipes and rebuilds the whole lineup on every run** (destructive to manual
  tweaks). We are **additive and non-destructive** — auto-tune never mutates or deletes
  existing channels.

## Scope — first slice (3-axis MVP)

The full issue lists nine axes. This MVP ships the **complete pipeline**
(enumerate → preview → select → bulk-create) for **three axes only**, the highest-value and
simplest, covering both TV and movie libraries:

| Axis | Enumerate (exact count, EF) | Generated channel query (SmartCollection) | Playback order | Channel name |
|------|------------------------------|-------------------------------------------|----------------|--------------|
| **TV Show** (24/7 per-show) | distinct shows with ≥ minItems episodes | `type:episode AND show_title:"X"` | `SeasonEpisode` | `X` |
| **TV Genre** | distinct genres on episodes/shows with ≥ minItems episodes | `type:episode AND genre:"X"` | `Shuffle` | `X` |
| **Movie Genre** | distinct genres on movies with ≥ minItems movies | `type:movie AND genre:"X"` | `Shuffle` | `X Movies` |

Deferred to follow-up PRs (not this MVP): TV Network, Movie Studio, Mixed Genre, Music
Genre, Smart-Collection→channel, Mixed Content; per-axis templates; "even show
distribution" balancing.

## Key architectural decision — enumerate via EF, persist via SmartCollection

- **Enumeration** (for the preview) uses **EF Core distinct + count queries** over the
  metadata tables (`ShowMetadata`, `GenreMetadata`, `MovieMetadata`, …). This gives *exact*
  item counts, which we need for the min-items threshold and the preview display.
- **Persistence** — each generated channel references a newly-created **SmartCollection**
  (a live Lucene query), not a static Collection. So a "Comedy" channel keeps picking up new
  comedies as the library grows — PseudoTV's regenerative intent, but non-destructive.
  #63's lineup `Item` DTO already accepts a `SmartCollectionId`.
- **Query authorship is server-side only.** The client never sends a Lucene string. The
  preview returns `{ axis, value }`; bulk-create takes `{ axis, value, … }` and the server
  regenerates the query. This avoids client-authored query injection and keeps the axis
  semantics in one place.

Query-value escaping: show titles / genres containing quotes or Lucene special characters
must be escaped when building `show_title:"…"` / `genre:"…"`. Enumeration returns raw
values; query generation escapes them.

## Coexistence model (additive, non-destructive)

- **Numbering**: the user picks a **starting channel number** for the batch (default `500`).
  Generated channels get sequential numbers from there, **skipping any already taken**.
- **Dedup**: on preview, a proposed channel whose generated **name already matches an
  existing channel** is flagged `alreadyExists: true` and is **de-selected by default** in
  the UI. Re-running auto-tune after adding library content therefore surfaces only
  genuinely-new channels.
- Auto-tune **never** edits or deletes an existing channel. Number/name collisions are
  resolved by skipping, never overwriting.
- **Concurrency**: numbers are re-validated at create time (another channel may have taken a
  number between preview and create); a now-taken number is skipped/reallocated, and that
  outcome is reported per-channel rather than failing the batch.

## API surface (under the frozen `/api/v1`)

All three follow `docs/api-conventions.md` (§1 controller shape, §2 request DTO with
`ToCommand()`, §3 error mapping, §7b post-commit side effects on `CancellationToken.None`,
§9 auth posture). New endpoints are additive to the frozen contract.

### 1. `POST /api/v1/channels/auto-tune/preview` (no writes)

Request:
```
{
  "axes": ["TvShow", "TvGenre", "MovieGenre"],   // subset, ≥1
  "minItems": 5,                                    // default 5
  "startingNumber": 500                             // default 500
}
```
Response — a list of proposed channels:
```
[
  { "axis": "TvShow", "value": "The Office", "name": "The Office",
    "number": 500, "itemCount": 201, "alreadyExists": false },
  { "axis": "MovieGenre", "value": "Action", "name": "Action Movies",
    "number": 501, "itemCount": 42, "alreadyExists": false },
  ...
]
```
Ordering of results: grouped by axis (TvShow, TvGenre, MovieGenre), then by value. Number
allocation is computed here so the UI can show final numbers; it is advisory (re-validated
at create).

### 2. `POST /api/v1/channels/auto-tune` (bulk create)

Request — the selected proposals echoed back (server regenerates the query from
`axis`+`value`):
```
{
  "templateId": <int>,
  "channels": [
    { "axis": "TvShow", "value": "The Office", "name": "The Office", "number": 500 },
    { "axis": "MovieGenre", "value": "Action", "name": "Action Movies", "number": 501 }
  ]
}
```
Response — partial-success list, mirroring the existing `ResetAllPlayoutsResult` /
`…ResponseModel` pattern (api-conventions §3a):
```
{
  "results": [
    { "name": "The Office", "status": "Created", "channelId": 88 },
    { "name": "Action Movies", "status": "Skipped", "reason": "number 501 already taken" },
    { "name": "Sci-Fi", "status": "Failed", "reason": "…" }
  ],
  "createdCount": 1, "skippedCount": 1, "failedCount": 1
}
```

Per selected channel the handler: (a) creates a SmartCollection with the server-generated
query, (b) allocates a free number, (c) calls the **#63 composite create-from-lineup**
handler with a single-item lineup referencing that `SmartCollectionId`, the batch
`templateId`, and an `Advanced.PlaybackOrder` override for the axis (SeasonEpisode / Shuffle).
Each channel is independent — one failure does not abort the batch. Side effects
(`BuildPlayout`, `RefreshChannelList`) are enqueued by the reused #63 handler on
`CancellationToken.None`.

## SPA

New wizard screen `web/src/screens/AutoTuneScreen.tsx` following `docs/spa-conventions.md`,
reached from the Channels area (a "Auto-tune channels" action). Flow:
1. **Configure** — axis checkboxes, starting number, min items, template select.
2. **Preview** — calls the preview endpoint; renders a table grouped by axis with per-row
   checkboxes, select-all-per-axis, item counts, and `alreadyExists` rows shown greyed and
   unchecked.
3. **Create** — posts selected rows to the bulk endpoint; shows a per-channel result summary
   (created / skipped / failed counts + any errors).

No client-side query construction; the screen only passes `axis`+`value` back.

## Testing / verification

- **Application handler tests** (NUnit + Shouldly, the in-memory Sqlite
  `EnsureCreatedAsync` harness from #28): enumeration distinct+count correctness, min-items
  threshold, name-dedup / `alreadyExists`, number allocation with gaps, and bulk-create
  partial-success (Created/Skipped/Failed). **Enumerate lazy LanguageExt returns in tests**
  (the #229 lesson — a lazy `Map`/`Seq` the test never enumerates hides write-path faults).
- **OpenAPI**: build the app project, then `./scripts/update-openapi.sh` + `npm run
  generate:api`; update `docs/api-conventions.md` checklist, regenerate `endpoint-index.md`.
- **Docs**: `docs/domain-model.md` (auto-tune as a second creation mode),
  `docs/spa-conventions.md` if a new pattern is introduced, `docs/decisions.md` (the
  enumerate-via-EF / persist-via-SmartCollection + additive-coexistence decisions),
  `docs/blazor-route-parity.md` + `docs/README.md` for the new screen/route.
- **Live-E2E (required — write-path handlers)** via `scripts/e2e-local.sh`: seed a tiny TV +
  movie library (testsrc MKVs + `LibraryPath` rows + scan per `docs/e2e-local.md`), run
  preview → create, and verify the generated channels appear and produce valid M3U/XMLTV.
  Never exercise download endpoints via browser tabs — curl them.

## Phasing (2 PRs)

- **PR1 — backend**: EF enumeration, preview endpoint, bulk-create endpoint,
  server-side SmartCollection query generation, handler tests, OpenAPI regen + doc updates.
  Write-path handlers → independent (cross-model or cold) review is mandatory.
- **PR2 — SPA wizard** + live-E2E verification.

## Defaults chosen (recorded so they can be revisited)

- `minItems` default **5**.
- Movie-genre channels suffixed **" Movies"**; TV genre and TV show names bare. (Disambiguates
  a genre that exists for both TV and movies, e.g. "Comedy" vs "Comedy Movies".)
- Per-axis playback order: TV Show → `SeasonEpisode`; TV/Movie Genre → `Shuffle`.
- One Channel Template for the whole batch (per-axis templates deferred).
- Server owns all query generation; the client never sends Lucene.

## Non-goals (MVP)

- The remaining six axes, per-axis templates, even-show-distribution balancing, editing
  generated channels in-wizard, scheduling filler/bumpers between items (channels inherit
  whatever the chosen template configures), and any destructive "rebuild my lineup" mode.
