- CreateAutoTunedChannelsRequest.ToCommand(): guard null Channels (was NREing on a request body that omits "channels", causing HTTP 500). - CreateAutoTunedChannelsHandler.CreateOne: when CreateChannelFromLineup returns Left (Skipped/Failed), roll back the just-created SmartCollection via DeleteSmartCollection so retries don't fail on SmartCollection-name uniqueness. Best-effort; the delete result does not change the outcome. - PreviewAutoTuneChannelsHandler: filter out proposals whose generated name exceeds the 50-char Channel.Name limit before number allocation, so numbers aren't wasted on proposals that can never be created. - docs/superpowers/specs/2026-07-16-auto-tuning-design.md: fix field-name drift in JSON examples (proposedNumber -> number, error -> reason) to match the actual AutoTuneProposal/AutoTuneChannelOutcome DTOs. Refs #69
9.6 KiB
Auto-Tuning: generate channels automatically from library metadata (#69)
Status: Design approved 2026-07-16. Implementation pending. Issue: ersatztv#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
ItemDTO already accepts aSmartCollectionId. - 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: trueand 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:
- Configure — axis checkboxes, starting number, min items, template select.
- Preview — calls the preview endpoint; renders a table grouped by axis with per-row
checkboxes, select-all-per-axis, item counts, and
alreadyExistsrows shown greyed and unchecked. - 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
EnsureCreatedAsyncharness 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 lazyMap/Seqthe test never enumerates hides write-path faults). - OpenAPI: build the app project, then
./scripts/update-openapi.sh+npm run generate:api; updatedocs/api-conventions.mdchecklist, regenerateendpoint-index.md. - Docs:
docs/domain-model.md(auto-tune as a second creation mode),docs/spa-conventions.mdif 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.mdfor the new screen/route. - Live-E2E (required — write-path handlers) via
scripts/e2e-local.sh: seed a tiny TV + movie library (testsrc MKVs +LibraryPathrows + scan perdocs/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)
minItemsdefault 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.