# 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": , "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.